2.5 update
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BumpCombat/Defense 2.5D/Artifact Drops")]
|
||||
public sealed class Defense25DArtifactDropConstants : ScriptableObject
|
||||
{
|
||||
[Min(0f)] public float FirstDropDelaySeconds = 60f;
|
||||
public float[] LaterDropTimesSeconds = { 180f, 360f };
|
||||
public Vector2 NearDistanceRange = new(2f, 4f);
|
||||
public Vector2 FarDistanceRange = new(18f, 26f);
|
||||
[Min(0f)] public float FarDropSeparation = 12f;
|
||||
[Min(0f)] public float InteractionRadius = 1.5f;
|
||||
public float[] ReliefSeconds = { 12f, 15f, 15f };
|
||||
[Min(0f)] public float DropFootprintRadius = .65f;
|
||||
[Min(0f)] public float RouteClearance = .75f;
|
||||
[Min(0f)] public float StructureClearance = 1.25f;
|
||||
[Min(1)] public int PositionSearchAttempts = 4096;
|
||||
|
||||
public bool Validate(out string error)
|
||||
{
|
||||
if (!IsFinite(FirstDropDelaySeconds) || FirstDropDelaySeconds < 0f ||
|
||||
!IsRangeValid(NearDistanceRange) || !IsRangeValid(FarDistanceRange) ||
|
||||
!IsFinite(FarDropSeparation) || FarDropSeparation < 0f ||
|
||||
!IsFinite(InteractionRadius) || InteractionRadius <= 0f ||
|
||||
!IsFinite(DropFootprintRadius) || DropFootprintRadius <= 0f ||
|
||||
!IsFinite(RouteClearance) || RouteClearance < 0f ||
|
||||
!IsFinite(StructureClearance) || StructureClearance < 0f ||
|
||||
PositionSearchAttempts < 1)
|
||||
{
|
||||
error = "Artifact drop timing, distance, clearance, or search settings are invalid.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (LaterDropTimesSeconds == null || LaterDropTimesSeconds.Length != 2 ||
|
||||
!IsFinite(LaterDropTimesSeconds[0]) || !IsFinite(LaterDropTimesSeconds[1]) ||
|
||||
LaterDropTimesSeconds[0] <= 0f || LaterDropTimesSeconds[1] <= LaterDropTimesSeconds[0])
|
||||
{
|
||||
error = "Exactly two increasing later artifact drop times are required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ReliefSeconds == null || ReliefSeconds.Length != 3)
|
||||
{
|
||||
error = "Each of the three artifact drops must define one relief duration.";
|
||||
return false;
|
||||
}
|
||||
foreach (float duration in ReliefSeconds)
|
||||
if (!IsFinite(duration) || duration <= 0f)
|
||||
{
|
||||
error = "Artifact relief durations must be finite and positive.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsRangeValid(Vector2 range) => IsFinite(range.x) && IsFinite(range.y)
|
||||
&& range.x >= 0f && range.y > range.x;
|
||||
|
||||
private static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1acfc52f8b0344e28dd641b5bb6d7e01
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -23,6 +23,12 @@ namespace BumpCombat.Constants
|
||||
public const float SlimeBodyCoreDiameterPixels = 11f;
|
||||
public const float GiantSlimeBodyCoreDiameterPixels = 22f;
|
||||
public const float LavaSlimeBodyCoreDiameterPixels = 13f;
|
||||
public const float CycloneFixedRingWidthPixels = 32f;
|
||||
public const float CycloneFixedRingDepthPixels = 13f;
|
||||
public const float CycloneFixedRingHeight = .035f;
|
||||
public const float CycloneBodyMaskFootClearancePixels = 4f;
|
||||
public static readonly float[] CycloneNormalOrbitWidthsPixels = { 44f, 60f, 76f };
|
||||
public static readonly float[] CycloneChargedOrbitWidthsPixels = { 40f, 48f, 56f, 64f, 72f, 80f, 92f };
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using BumpCombat.Defense;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
[Serializable]
|
||||
public sealed class Defense25DFacilityEffectStyle
|
||||
{
|
||||
[SerializeField, Min(.001f)] private float targetDurationSeconds = .6f;
|
||||
[SerializeField, Min(.001f)] private float groundScale = 1f;
|
||||
[SerializeField, Min(.001f)] private float verticalScale = 1f;
|
||||
[SerializeField] private float groundYOffset = .04f;
|
||||
[SerializeField, Min(0f)] private float verticalHeight = .7f;
|
||||
[SerializeField] private Color groundTint = Color.white;
|
||||
[SerializeField] private Color verticalTint = Color.white;
|
||||
|
||||
public float TargetDurationSeconds => targetDurationSeconds;
|
||||
public float GroundScale => groundScale;
|
||||
public float VerticalScale => verticalScale;
|
||||
public float GroundYOffset => groundYOffset;
|
||||
public float VerticalHeight => verticalHeight;
|
||||
public Color GroundTint => groundTint;
|
||||
public Color VerticalTint => verticalTint;
|
||||
|
||||
internal bool TryValidate(string name, out string error)
|
||||
{
|
||||
if (!IsFinite(targetDurationSeconds) || targetDurationSeconds <= 0f
|
||||
|| !IsFinite(groundScale) || groundScale <= 0f
|
||||
|| !IsFinite(verticalScale) || verticalScale <= 0f
|
||||
|| !IsFinite(groundYOffset) || !IsFinite(verticalHeight) || verticalHeight < 0f
|
||||
|| !IsFinite(groundTint) || !IsFinite(verticalTint))
|
||||
{
|
||||
error = $"{name} has an invalid duration, layer transform, scale, or tint.";
|
||||
return false;
|
||||
}
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value);
|
||||
|
||||
private static bool IsFinite(Color color) => IsFinite(color.r) && IsFinite(color.g)
|
||||
&& IsFinite(color.b) && IsFinite(color.a);
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "BumpCombat/Constants/Defense 2.5D Facility Visuals")]
|
||||
public sealed class Defense25DFacilityVisualConstants : ScriptableObject
|
||||
{
|
||||
[SerializeField] private string impactWaveResourceKey = "Effects/Lightning/ImpactWave";
|
||||
[SerializeField] private string meteorImpactResourceKey = "Effects/Fire/Meteor-Impact";
|
||||
[SerializeField] private string imbuingResourceKey = "Effects/Support/Imbuing";
|
||||
[SerializeField] private int groundSortingOrder = 13;
|
||||
[SerializeField] private int verticalSortingOrder = 14;
|
||||
[SerializeField] private Defense25DFacilityEffectStyle portalSpawn = new();
|
||||
[SerializeField] private Defense25DFacilityEffectStyle portalBurst = new();
|
||||
[SerializeField] private Defense25DFacilityEffectStyle homeSpawn = new();
|
||||
[SerializeField] private Defense25DFacilityEffectStyle homeBurst = new();
|
||||
[SerializeField] private Defense25DFacilityEffectStyle towerPadSpawn = new();
|
||||
[SerializeField] private Defense25DFacilityEffectStyle towerUpgrade = new();
|
||||
|
||||
public string ImpactWaveResourceKey => impactWaveResourceKey;
|
||||
public string MeteorImpactResourceKey => meteorImpactResourceKey;
|
||||
public string ImbuingResourceKey => imbuingResourceKey;
|
||||
public int GroundSortingOrder => groundSortingOrder;
|
||||
public int VerticalSortingOrder => verticalSortingOrder;
|
||||
public Defense25DFacilityEffectStyle PortalSpawn => portalSpawn;
|
||||
public Defense25DFacilityEffectStyle PortalBurst => portalBurst;
|
||||
public Defense25DFacilityEffectStyle HomeSpawn => homeSpawn;
|
||||
public Defense25DFacilityEffectStyle HomeBurst => homeBurst;
|
||||
public Defense25DFacilityEffectStyle TowerPadSpawn => towerPadSpawn;
|
||||
public Defense25DFacilityEffectStyle TowerUpgrade => towerUpgrade;
|
||||
|
||||
public bool TryValidate(out string error)
|
||||
{
|
||||
if (!ValidateResource(impactWaveResourceKey, "ImpactWave", out error)
|
||||
|| !ValidateResource(meteorImpactResourceKey, "Meteor-Impact", out error)
|
||||
|| !ValidateResource(imbuingResourceKey, "Imbuing", out error))
|
||||
return false;
|
||||
if (!ValidateStyle(portalSpawn, nameof(portalSpawn), out error)
|
||||
|| !ValidateStyle(portalBurst, nameof(portalBurst), out error)
|
||||
|| !ValidateStyle(homeSpawn, nameof(homeSpawn), out error)
|
||||
|| !ValidateStyle(homeBurst, nameof(homeBurst), out error)
|
||||
|| !ValidateStyle(towerPadSpawn, nameof(towerPadSpawn), out error)
|
||||
|| !ValidateStyle(towerUpgrade, nameof(towerUpgrade), out error))
|
||||
return false;
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetPlaybackScale(string resourceKey, float targetDurationSeconds,
|
||||
out float durationScale, out string error)
|
||||
{
|
||||
durationScale = 1f;
|
||||
if (!IsFinite(targetDurationSeconds) || targetDurationSeconds <= 0f)
|
||||
{
|
||||
error = "A positive finite target duration is required.";
|
||||
return false;
|
||||
}
|
||||
int expectedCount = DefenseTowerArt.ExpectedFrameCount(resourceKey);
|
||||
Sprite[] frames = DefenseTowerArt.Load(resourceKey);
|
||||
if (expectedCount <= 0 || frames == null || frames.Length != expectedCount)
|
||||
{
|
||||
error = $"Resource '{resourceKey}' resolved {frames?.Length ?? 0} frames; expected {expectedCount}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
float sourceDuration = 0f;
|
||||
for (int i = 0; i < frames.Length; i++)
|
||||
sourceDuration += DefenseTowerArt.FrameSeconds(resourceKey, i);
|
||||
if (!IsFinite(sourceDuration) || sourceDuration <= 0f)
|
||||
{
|
||||
error = $"Resource '{resourceKey}' has no positive authored frame duration.";
|
||||
return false;
|
||||
}
|
||||
|
||||
durationScale = targetDurationSeconds / sourceDuration;
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ValidateResource(string resourceKey, string displayName, out string error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resourceKey))
|
||||
{
|
||||
error = $"{displayName} resource key is required.";
|
||||
return false;
|
||||
}
|
||||
int expectedCount = DefenseTowerArt.ExpectedFrameCount(resourceKey);
|
||||
Sprite[] frames = DefenseTowerArt.Load(resourceKey);
|
||||
if (expectedCount <= 0 || frames == null || frames.Length != expectedCount)
|
||||
{
|
||||
error = $"{displayName} resource '{resourceKey}' resolved {frames?.Length ?? 0} frames; expected {expectedCount}.";
|
||||
return false;
|
||||
}
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ValidateStyle(Defense25DFacilityEffectStyle style, string name, out string error)
|
||||
{
|
||||
if (style == null)
|
||||
{
|
||||
error = $"{name} style is required.";
|
||||
return false;
|
||||
}
|
||||
return style.TryValidate(name, out error);
|
||||
}
|
||||
|
||||
private static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2133d01a37e847e4aead6c0807061dbd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,512 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Enemies;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// Tier IDs passed to the Global Wave Director. These are deliberately 0-based:
|
||||
/// 0=T1, 1=T2, and 2=T3. They are not the six IDs used by the threat curve.
|
||||
/// </summary>
|
||||
public enum Defense25DMonsterTierId
|
||||
{
|
||||
T1 = 0,
|
||||
T2 = 1,
|
||||
T3 = 2,
|
||||
}
|
||||
|
||||
public enum Defense25DMonsterAttackArchetype
|
||||
{
|
||||
Contact = 0,
|
||||
Melee = 1,
|
||||
Charge = 2,
|
||||
Projectile = 3,
|
||||
Area = 4,
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class Defense25DMonsterCombatProfile
|
||||
{
|
||||
[SerializeField] private string definitionBasisId;
|
||||
[SerializeField] private string attackAction = "Attack";
|
||||
[SerializeField] private Defense25DMonsterAttackArchetype archetype;
|
||||
[SerializeField] private EnemyAttackShape attackShape;
|
||||
[SerializeField, Min(0f)] private float bodyRadius = 0.4f;
|
||||
[SerializeField, Min(0f)] private float moveSpeed = 1.5f;
|
||||
[SerializeField, Min(0f)] private float attackDamage = 8f;
|
||||
[SerializeField, Min(0f)] private float warningSeconds = 0.6f;
|
||||
[SerializeField, Min(0f)] private float activeSeconds = 0.2f;
|
||||
[SerializeField, Min(0f)] private float recoverySeconds = 0.7f;
|
||||
[SerializeField, Min(0f)] private float attackRange = 0.9f;
|
||||
[SerializeField, Min(0f)] private float attackLength = 0.8f;
|
||||
[SerializeField, Min(0f)] private float attackWidth = 0.6f;
|
||||
[SerializeField, Min(0f)] private float attackRadius = 0.5f;
|
||||
[SerializeField, Min(0f)] private float projectileSpeed;
|
||||
[SerializeField, Min(0f)] private float projectileRadius;
|
||||
[SerializeField] private bool isFlying;
|
||||
|
||||
public string DefinitionBasisId => definitionBasisId;
|
||||
public string AttackAction => attackAction;
|
||||
public Defense25DMonsterAttackArchetype Archetype => archetype;
|
||||
public EnemyAttackShape AttackShape => attackShape;
|
||||
public float BodyRadius => bodyRadius;
|
||||
public float MoveSpeed => moveSpeed;
|
||||
public float AttackDamage => attackDamage;
|
||||
public float WarningSeconds => warningSeconds;
|
||||
public float ActiveSeconds => activeSeconds;
|
||||
public float RecoverySeconds => recoverySeconds;
|
||||
public float AttackRange => attackRange;
|
||||
public float AttackLength => attackLength;
|
||||
public float AttackWidth => attackWidth;
|
||||
public float AttackRadius => attackRadius;
|
||||
public float ProjectileSpeed => projectileSpeed;
|
||||
public float ProjectileRadius => projectileRadius;
|
||||
public bool IsFlying => isFlying;
|
||||
|
||||
public bool TryValidate(out string error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(definitionBasisId))
|
||||
return Fail("CombatProfile.DefinitionBasisId is required.", out error);
|
||||
if (string.IsNullOrWhiteSpace(attackAction))
|
||||
return Fail("CombatProfile.AttackAction is required.", out error);
|
||||
if (!Enum.IsDefined(typeof(Defense25DMonsterAttackArchetype), archetype)
|
||||
|| !Enum.IsDefined(typeof(EnemyAttackShape), attackShape))
|
||||
return Fail("CombatProfile has an unsupported attack archetype or shape.", out error);
|
||||
if (!IsFinite(bodyRadius) || bodyRadius <= 0f
|
||||
|| !IsFinite(moveSpeed) || moveSpeed <= 0f
|
||||
|| !IsFinite(attackDamage) || attackDamage <= 0f
|
||||
|| !IsFinite(warningSeconds) || warningSeconds <= 0f
|
||||
|| !IsFinite(activeSeconds) || activeSeconds <= 0f
|
||||
|| !IsFinite(recoverySeconds) || recoverySeconds <= 0f
|
||||
|| !IsFinite(attackRange) || attackRange <= 0f
|
||||
|| !IsFinite(attackLength) || attackLength < 0f
|
||||
|| !IsFinite(attackWidth) || attackWidth < 0f
|
||||
|| !IsFinite(attackRadius) || attackRadius < 0f
|
||||
|| !IsFinite(projectileSpeed) || projectileSpeed < 0f
|
||||
|| !IsFinite(projectileRadius) || projectileRadius < 0f)
|
||||
return Fail("CombatProfile movement, body, and attack timings must be finite and positive; shape dimensions must be finite and non-negative.", out error);
|
||||
|
||||
if (archetype == Defense25DMonsterAttackArchetype.Projectile && projectileSpeed <= 0f)
|
||||
return Fail("Projectile CombatProfile requires a positive ProjectileSpeed.", out error);
|
||||
if (archetype == Defense25DMonsterAttackArchetype.Area && attackRadius <= 0f)
|
||||
return Fail("Area CombatProfile requires a positive AttackRadius.", out error);
|
||||
if (archetype == Defense25DMonsterAttackArchetype.Charge && attackLength <= 0f)
|
||||
return Fail("Charge CombatProfile requires a positive AttackLength.", out error);
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value);
|
||||
|
||||
private static bool Fail(string message, out string error)
|
||||
{
|
||||
error = message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class Defense25DMonsterPoolEntry
|
||||
{
|
||||
[SerializeField] private EnemyDefinition definition;
|
||||
[SerializeField] private GameObject visualPrefab;
|
||||
[SerializeField] private string enemyDefinitionId;
|
||||
[SerializeField] private string sourceCharacterId;
|
||||
[SerializeField, Min(0f)] private float weight = 1f;
|
||||
[SerializeField, Min(0f)] private float threatCost = 1f;
|
||||
[SerializeField] private Defense25DMonsterCombatProfile combatProfile;
|
||||
|
||||
public EnemyDefinition Definition => definition;
|
||||
public GameObject VisualPrefab => visualPrefab;
|
||||
public string EnemyDefinitionId => enemyDefinitionId;
|
||||
public string SourceCharacterId => sourceCharacterId;
|
||||
public float Weight => weight;
|
||||
public float ThreatCost => threatCost;
|
||||
public Defense25DMonsterCombatProfile CombatProfile => combatProfile;
|
||||
|
||||
public Defense25DWaveEnemyEntry ToDirectorEntry()
|
||||
{
|
||||
return new Defense25DWaveEnemyEntry(enemyDefinitionId, threatCost, weight);
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class Defense25DMonsterTierPool
|
||||
{
|
||||
[SerializeField] private int tierId;
|
||||
[SerializeField, Min(0f)] private float fromCombatSeconds;
|
||||
[SerializeField] private Defense25DMonsterPoolEntry[] entries = Array.Empty<Defense25DMonsterPoolEntry>();
|
||||
|
||||
public int TierId => tierId;
|
||||
public float FromCombatSeconds => fromCombatSeconds;
|
||||
public IReadOnlyList<Defense25DMonsterPoolEntry> Entries => entries ?? Array.Empty<Defense25DMonsterPoolEntry>();
|
||||
|
||||
internal Defense25DMonsterPoolEntry[] EntryArray => entries;
|
||||
}
|
||||
|
||||
public sealed class Defense25DWaveComposition
|
||||
{
|
||||
private readonly Defense25DMonsterPoolEntry[] selectedEntries;
|
||||
private readonly EnemyDefinition[] enemyDefinitions;
|
||||
private readonly string[] enemyDefinitionIds;
|
||||
private readonly float[] threatCosts;
|
||||
|
||||
public int WaveId { get; }
|
||||
public int Seed { get; }
|
||||
public float NominalStart { get; }
|
||||
public int TierId { get; }
|
||||
public float ThreatBudget { get; }
|
||||
public float ComposedThreat { get; }
|
||||
public bool DiversityConstraintRelaxed { get; }
|
||||
public IReadOnlyList<Defense25DMonsterPoolEntry> SelectedEntries => selectedEntries;
|
||||
public IReadOnlyList<EnemyDefinition> EnemyDefinitions => enemyDefinitions;
|
||||
public IReadOnlyList<string> EnemyDefinitionIds => enemyDefinitionIds;
|
||||
public IReadOnlyList<float> ThreatCosts => threatCosts;
|
||||
|
||||
internal Defense25DWaveComposition(int waveId, int seed, float nominalStart, int tierId, float threatBudget,
|
||||
float composedThreat, bool diversityConstraintRelaxed, Defense25DMonsterPoolEntry[] selected)
|
||||
{
|
||||
WaveId = waveId;
|
||||
Seed = seed;
|
||||
NominalStart = nominalStart;
|
||||
TierId = tierId;
|
||||
ThreatBudget = threatBudget;
|
||||
ComposedThreat = composedThreat;
|
||||
DiversityConstraintRelaxed = diversityConstraintRelaxed;
|
||||
selectedEntries = (Defense25DMonsterPoolEntry[])selected.Clone();
|
||||
enemyDefinitions = new EnemyDefinition[selected.Length];
|
||||
enemyDefinitionIds = new string[selected.Length];
|
||||
threatCosts = new float[selected.Length];
|
||||
for (int i = 0; i < selected.Length; i++)
|
||||
{
|
||||
enemyDefinitions[i] = selected[i].Definition;
|
||||
enemyDefinitionIds[i] = selected[i].EnemyDefinitionId;
|
||||
threatCosts[i] = selected[i].ThreatCost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "BumpCombat/Defense 2.5D/Monster Pools")]
|
||||
public sealed class Defense25DMonsterPoolDefinition : ScriptableObject
|
||||
{
|
||||
public const float Tier2StartSeconds = 180f;
|
||||
public const float Tier3StartSeconds = 360f;
|
||||
|
||||
private static readonly string[][] AllowedIdsByTier =
|
||||
{
|
||||
new[] { "Slime", "Bat", "Skeleton", "Orc", "Hellbat", "Hellhound", "LavaSlime" },
|
||||
new[] { "ArmoredSkeleton", "SkeletonArcher", "Werewolf", "Werebear", "ArmoredOrc", "BloodMonster_A", "BloodMonster_B", "EyeballMonster", "Ghostfire" },
|
||||
new[] { "GreatswordSkeleton", "EliteOrc", "OrcRider", "Demon_A", "Demon_B", "Demon_C", "Demon_D", "Demon_E", "Demoness_A", "Demoness_B", "FlameGolem", "Minotaur" },
|
||||
};
|
||||
|
||||
private static readonly string[] ExcludedIds =
|
||||
{
|
||||
"Archer", "Knight", "KnightTemplar", "Lancer", "Priest", "Soldier", "Swordsman",
|
||||
"Wizard", "ArmoredAxeman", "BlackKnight_A", "BlackKnight_B", "BlackKnight_C", "Warlock",
|
||||
};
|
||||
|
||||
private static readonly string[] ExistingDefinitionBasisIds =
|
||||
{
|
||||
"Slime", "Bat", "Skeleton", "ArmoredSkeleton", "SkeletonArcher", "Werewolf", "Werebear", "GreatswordSkeleton",
|
||||
};
|
||||
|
||||
[SerializeField] private Defense25DMonsterTierPool[] tiers = Array.Empty<Defense25DMonsterTierPool>();
|
||||
|
||||
public IReadOnlyList<Defense25DMonsterTierPool> Tiers => tiers ?? Array.Empty<Defense25DMonsterTierPool>();
|
||||
|
||||
/// <summary>Converts nominal combat time to the pool's 0-based tier ID.</summary>
|
||||
public static bool TryGetTierForNominalStart(float nominalStart, out int tierId)
|
||||
{
|
||||
tierId = -1;
|
||||
if (!Defense25DMonsterCombatProfile.IsFinite(nominalStart) || nominalStart < 0f)
|
||||
return false;
|
||||
|
||||
tierId = nominalStart < Tier2StartSeconds ? (int)Defense25DMonsterTierId.T1
|
||||
: nominalStart < Tier3StartSeconds ? (int)Defense25DMonsterTierId.T2
|
||||
: (int)Defense25DMonsterTierId.T3;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns candidates in the format consumed by Defense25DWaveDirector.
|
||||
/// Only normalized IDs 0, 1, and 2 are valid; threat-curve IDs are never remapped.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Defense25DWaveEnemyEntry> GetDirectorEntriesForTier(int tierId)
|
||||
{
|
||||
if (tierId < 0 || tierId > 2)
|
||||
throw new ArgumentOutOfRangeException(nameof(tierId), tierId,
|
||||
"Monster tier ID must be normalized to 0=T1, 1=T2, or 2=T3.");
|
||||
|
||||
Defense25DMonsterTierPool tier = FindTier(tierId);
|
||||
if (tier == null)
|
||||
throw new InvalidOperationException($"Monster pool is missing tier {tierId}.");
|
||||
|
||||
Defense25DMonsterPoolEntry[] entries = tier.EntryArray;
|
||||
if (entries == null || entries.Length == 0)
|
||||
throw new InvalidOperationException($"Monster pool tier {tierId} has no entries.");
|
||||
|
||||
Defense25DWaveEnemyEntry[] result = new Defense25DWaveEnemyEntry[entries.Length];
|
||||
for (int i = 0; i < entries.Length; i++)
|
||||
{
|
||||
Defense25DMonsterPoolEntry entry = entries[i];
|
||||
if (entry == null || string.IsNullOrWhiteSpace(entry.EnemyDefinitionId)
|
||||
|| entry.ThreatCost <= 0f || entry.Weight <= 0f)
|
||||
throw new InvalidOperationException($"Monster pool tier {tierId} contains an invalid entry at index {i}.");
|
||||
|
||||
result[i] = entry.ToDirectorEntry();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a deterministic, weighted-with-replacement composition from the tier selected
|
||||
/// by nominalStart. The Director consumes this result when it distributes entries to
|
||||
/// Nodes and schedules their spawn windows; actualStart is intentionally not an input.
|
||||
/// </summary>
|
||||
public Defense25DWaveComposition BuildWaveComposition(int waveId, float nominalStart,
|
||||
float threatBudget, int waveSeed)
|
||||
{
|
||||
if (waveId < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(waveId), "Wave ID must be non-negative.");
|
||||
if (!TryGetTierForNominalStart(nominalStart, out int tierId))
|
||||
throw new ArgumentOutOfRangeException(nameof(nominalStart), "Nominal start must be finite and non-negative.");
|
||||
if (!Defense25DMonsterCombatProfile.IsFinite(threatBudget) || threatBudget <= 0f)
|
||||
throw new ArgumentOutOfRangeException(nameof(threatBudget), "Threat budget must be finite and positive.");
|
||||
if (!TryValidate(out string validationError))
|
||||
throw new InvalidOperationException($"Monster pool is invalid: {validationError}");
|
||||
|
||||
Defense25DMonsterPoolEntry[] pool = FindTier(tierId).EntryArray;
|
||||
DeterministicRandom random = new DeterministicRandom(MixSeed(waveSeed, waveId));
|
||||
List<Defense25DMonsterPoolEntry> composition = new List<Defense25DMonsterPoolEntry>();
|
||||
Dictionary<string, double> threatBySpecies = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
double spent = 0d;
|
||||
bool diversityRelaxed = false;
|
||||
|
||||
while (spent < threatBudget)
|
||||
{
|
||||
List<Defense25DMonsterPoolEntry> eligible = new List<Defense25DMonsterPoolEntry>(pool.Length);
|
||||
foreach (Defense25DMonsterPoolEntry entry in pool)
|
||||
{
|
||||
threatBySpecies.TryGetValue(entry.EnemyDefinitionId, out double current);
|
||||
if (current + entry.ThreatCost <= threatBudget * 0.60d + 0.0001d)
|
||||
eligible.Add(entry);
|
||||
}
|
||||
|
||||
if (eligible.Count == 0)
|
||||
{
|
||||
// Relax once a constrained draw has no candidates. This guarantees progress
|
||||
// and records that the 60% target could not be maintained for this wave.
|
||||
eligible.AddRange(pool);
|
||||
diversityRelaxed = true;
|
||||
}
|
||||
|
||||
Defense25DMonsterPoolEntry selected = SelectWeighted(eligible, ref random);
|
||||
composition.Add(selected);
|
||||
spent += selected.ThreatCost;
|
||||
threatBySpecies.TryGetValue(selected.EnemyDefinitionId, out double previousThreat);
|
||||
threatBySpecies[selected.EnemyDefinitionId] = previousThreat + selected.ThreatCost;
|
||||
if (double.IsNaN(spent) || double.IsInfinity(spent))
|
||||
throw new InvalidOperationException("Wave composition threat total overflowed.");
|
||||
}
|
||||
|
||||
return new Defense25DWaveComposition(waveId, waveSeed, nominalStart, tierId, threatBudget,
|
||||
(float)spent, diversityRelaxed, composition.ToArray());
|
||||
}
|
||||
|
||||
public bool TryGetEntry(string enemyDefinitionId, out Defense25DMonsterPoolEntry entry)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(enemyDefinitionId) && tiers != null)
|
||||
{
|
||||
foreach (Defense25DMonsterTierPool tier in tiers)
|
||||
{
|
||||
if (tier?.EntryArray == null) continue;
|
||||
foreach (Defense25DMonsterPoolEntry candidate in tier.EntryArray)
|
||||
{
|
||||
if (candidate != null && string.Equals(candidate.EnemyDefinitionId, enemyDefinitionId, StringComparison.Ordinal))
|
||||
{
|
||||
entry = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entry = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryValidate(out string error)
|
||||
{
|
||||
if (tiers == null || tiers.Length != 3)
|
||||
return Fail("Monster pool must define exactly three tiers (IDs 0, 1, 2).", out error);
|
||||
|
||||
Dictionary<string, int> seen = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
HashSet<string> seenSources = new HashSet<string>(StringComparer.Ordinal);
|
||||
HashSet<EnemyDefinition> seenDefinitions = new HashSet<EnemyDefinition>();
|
||||
HashSet<GameObject> seenVisualPrefabs = new HashSet<GameObject>();
|
||||
HashSet<Defense25DMonsterCombatProfile> seenProfiles = new HashSet<Defense25DMonsterCombatProfile>();
|
||||
for (int tierIndex = 0; tierIndex < tiers.Length; tierIndex++)
|
||||
{
|
||||
Defense25DMonsterTierPool tier = tiers[tierIndex];
|
||||
float expectedStart = tierIndex == 0 ? 0f : tierIndex == 1 ? Tier2StartSeconds : Tier3StartSeconds;
|
||||
if (tier == null || tier.TierId != tierIndex || !Defense25DMonsterCombatProfile.IsFinite(tier.FromCombatSeconds)
|
||||
|| tier.FromCombatSeconds != expectedStart)
|
||||
return Fail($"Tier at index {tierIndex} must use ID {tierIndex} and start at {expectedStart} seconds.", out error);
|
||||
|
||||
Defense25DMonsterPoolEntry[] entries = tier.EntryArray;
|
||||
if (entries == null || entries.Length == 0)
|
||||
return Fail($"Tier {tierIndex} must not be empty.", out error);
|
||||
|
||||
foreach (Defense25DMonsterPoolEntry entry in entries)
|
||||
{
|
||||
if (entry == null)
|
||||
return Fail($"Tier {tierIndex} contains a null entry.", out error);
|
||||
if (entry.Definition == null)
|
||||
return Fail($"Tier {tierIndex} entry {entry.EnemyDefinitionId} has no EnemyDefinition reference.", out error);
|
||||
if (!seenDefinitions.Add(entry.Definition))
|
||||
return Fail($"Species {entry.EnemyDefinitionId} reuses another species' EnemyDefinition asset.", out error);
|
||||
if (entry.VisualPrefab == null)
|
||||
return Fail($"Species {entry.EnemyDefinitionId} has no visual prefab reference.", out error);
|
||||
if (!seenVisualPrefabs.Add(entry.VisualPrefab))
|
||||
return Fail($"Species {entry.EnemyDefinitionId} reuses another species' visual prefab.", out error);
|
||||
if (string.IsNullOrWhiteSpace(entry.EnemyDefinitionId))
|
||||
return Fail($"Tier {tierIndex} contains an entry without EnemyDefinitionId.", out error);
|
||||
if (IsExcluded(entry.EnemyDefinitionId) || IsExcluded(entry.SourceCharacterId))
|
||||
return Fail($"Excluded human/final-boss species cannot enter the ordinary pool: {entry.EnemyDefinitionId}.", out error);
|
||||
if (!Contains(AllowedIdsByTier[tierIndex], entry.EnemyDefinitionId))
|
||||
return Fail($"Species {entry.EnemyDefinitionId} is not explicitly allowed in tier {tierIndex}.", out error);
|
||||
if (!string.Equals(ExpectedSourceId(entry.EnemyDefinitionId), entry.SourceCharacterId, StringComparison.Ordinal))
|
||||
return Fail($"Species {entry.EnemyDefinitionId} has mismatched SourceCharacterId '{entry.SourceCharacterId}'.", out error);
|
||||
if (!IsFinitePositive(entry.Weight) || !IsFinitePositive(entry.ThreatCost))
|
||||
return Fail($"Species {entry.EnemyDefinitionId} requires finite positive Weight and ThreatCost.", out error);
|
||||
if (!IsThreatCostInTierRange(tierIndex, entry.ThreatCost))
|
||||
return Fail($"Species {entry.EnemyDefinitionId} ThreatCost is outside the initial tier range.", out error);
|
||||
if (!seen.TryAdd(entry.EnemyDefinitionId, tierIndex))
|
||||
return Fail($"Species {entry.EnemyDefinitionId} appears more than once.", out error);
|
||||
if (!seenSources.Add(entry.SourceCharacterId))
|
||||
return Fail($"Source character {entry.SourceCharacterId} appears more than once.", out error);
|
||||
if (entry.CombatProfile == null)
|
||||
return Fail($"Species {entry.EnemyDefinitionId} has no CombatProfile.", out error);
|
||||
if (!seenProfiles.Add(entry.CombatProfile))
|
||||
return Fail($"Species {entry.EnemyDefinitionId} reuses another species' CombatProfile.", out error);
|
||||
if (!entry.CombatProfile.TryValidate(out string profileError))
|
||||
return Fail($"Species {entry.EnemyDefinitionId} has invalid CombatProfile: {profileError}", out error);
|
||||
if (!Contains(ExistingDefinitionBasisIds, entry.CombatProfile.DefinitionBasisId))
|
||||
return Fail($"Species {entry.EnemyDefinitionId} must name one of the existing roster definitions as its profile basis.", out error);
|
||||
}
|
||||
}
|
||||
|
||||
for (int tierIndex = 0; tierIndex < AllowedIdsByTier.Length; tierIndex++)
|
||||
{
|
||||
foreach (string allowedId in AllowedIdsByTier[tierIndex])
|
||||
{
|
||||
if (!seen.TryGetValue(allowedId, out int actualTier) || actualTier != tierIndex)
|
||||
return Fail($"Allowed species {allowedId} is missing from tier {tierIndex}.", out error);
|
||||
}
|
||||
}
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private Defense25DMonsterTierPool FindTier(int tierId)
|
||||
{
|
||||
if (tiers == null) return null;
|
||||
foreach (Defense25DMonsterTierPool tier in tiers)
|
||||
if (tier != null && tier.TierId == tierId) return tier;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsThreatCostInTierRange(int tierId, float cost)
|
||||
{
|
||||
return tierId switch
|
||||
{
|
||||
0 => cost >= 0.8f && cost <= 1.3f,
|
||||
1 => cost >= 1.5f && cost <= 2.3f,
|
||||
2 => cost >= 2.4f && cost <= 4f,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static Defense25DMonsterPoolEntry SelectWeighted(
|
||||
IReadOnlyList<Defense25DMonsterPoolEntry> candidates, ref DeterministicRandom random)
|
||||
{
|
||||
double totalWeight = 0d;
|
||||
for (int i = 0; i < candidates.Count; i++) totalWeight += candidates[i].Weight;
|
||||
double roll = random.NextDouble() * totalWeight;
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
roll -= candidates[i].Weight;
|
||||
if (roll < 0d) return candidates[i];
|
||||
}
|
||||
return candidates[candidates.Count - 1];
|
||||
}
|
||||
|
||||
private static uint MixSeed(int waveSeed, int waveId)
|
||||
{
|
||||
uint value = unchecked((uint)waveSeed) ^ (unchecked((uint)waveId) * 0x9E3779B9u) ^ 0xA341316Cu;
|
||||
return value == 0u ? 0x6D2B79F5u : value;
|
||||
}
|
||||
|
||||
private struct DeterministicRandom
|
||||
{
|
||||
private uint state;
|
||||
|
||||
public DeterministicRandom(uint seed) => state = seed == 0u ? 0x6D2B79F5u : seed;
|
||||
|
||||
public double NextDouble()
|
||||
{
|
||||
// xorshift32 is explicit here so seeded composition does not depend on
|
||||
// System.Random's implementation version or unrelated global random calls.
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
return (state >> 8) * (1.0 / 16777216.0);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExpectedSourceId(string enemyDefinitionId)
|
||||
{
|
||||
return enemyDefinitionId switch
|
||||
{
|
||||
"LavaSlime" => "Lava Slime",
|
||||
"ArmoredSkeleton" => "Armored Skeleton",
|
||||
"SkeletonArcher" => "Skeleton Archer",
|
||||
"ArmoredOrc" => "Armored Orc",
|
||||
"BloodMonster_A" => "Blood Monster_A",
|
||||
"BloodMonster_B" => "Blood Monster_B",
|
||||
"EyeballMonster" => "Eyeball Monster",
|
||||
"GreatswordSkeleton" => "Greatsword Skeleton",
|
||||
"EliteOrc" => "Elite Orc",
|
||||
"OrcRider" => "Orc rider",
|
||||
"FlameGolem" => "Flame Golem",
|
||||
"Minotaur" => "Minotaur",
|
||||
_ => enemyDefinitionId,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsExcluded(string id)
|
||||
{
|
||||
return Contains(ExcludedIds, id) || string.Equals(id, "Necromancer", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool Contains(string[] values, string candidate)
|
||||
{
|
||||
if (string.IsNullOrEmpty(candidate)) return false;
|
||||
foreach (string value in values)
|
||||
if (string.Equals(value, candidate, StringComparison.Ordinal)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsFinitePositive(float value) => Defense25DMonsterCombatProfile.IsFinite(value) && value > 0f;
|
||||
|
||||
private static bool Fail(string message, out string error)
|
||||
{
|
||||
error = message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aae25a95d9f34b759f1de6defa77b547
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,232 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BumpCombat/Defense/25D Monster Source Motion Catalog")]
|
||||
public sealed class Defense25DMonsterSourceMotionCatalog : ScriptableObject
|
||||
{
|
||||
[SerializeField] private Defense25DMonsterSourceMotionEntry[] entries =
|
||||
Array.Empty<Defense25DMonsterSourceMotionEntry>();
|
||||
|
||||
[NonSerialized] private Dictionary<string, Sprite[]> loadedFrames;
|
||||
|
||||
public IReadOnlyList<Defense25DMonsterSourceMotionEntry> Entries =>
|
||||
entries ?? Array.Empty<Defense25DMonsterSourceMotionEntry>();
|
||||
|
||||
/// <summary>
|
||||
/// Resolves only the exact pool species and authored attack action. Resource keys
|
||||
/// are relative to a Unity Resources folder, for example
|
||||
/// "Enemies/Roster25D-v1/Orc/Orc/Orc_Attack01". No generic species fallback is used.
|
||||
/// </summary>
|
||||
public bool TryResolve(Defense25DMonsterPoolEntry poolEntry,
|
||||
out Defense25DMonsterSourceMotion motion, out string error)
|
||||
{
|
||||
motion = null;
|
||||
if (poolEntry == null || poolEntry.CombatProfile == null)
|
||||
return Fail("A pool entry with a CombatProfile is required.", out error);
|
||||
|
||||
Defense25DMonsterSourceMotionEntry catalogEntry = FindEntry(
|
||||
poolEntry.EnemyDefinitionId, poolEntry.SourceCharacterId);
|
||||
if (catalogEntry == null)
|
||||
return Fail($"No source-motion catalog entry matches '{poolEntry.EnemyDefinitionId}' / '{poolEntry.SourceCharacterId}'.", out error);
|
||||
|
||||
string requiredAction = poolEntry.CombatProfile.AttackAction;
|
||||
if (!catalogEntry.TryFindAttack(requiredAction, out Defense25DMonsterSourceMotionAction attack))
|
||||
return Fail($"Source '{poolEntry.SourceCharacterId}' has no exact '{requiredAction}' attack key.", out error);
|
||||
|
||||
if (!IsFinitePositive(catalogEntry.FrameSeconds))
|
||||
return Fail($"Source '{poolEntry.SourceCharacterId}' has an invalid frame duration.", out error);
|
||||
|
||||
if (!TryLoadOptional(catalogEntry.IdleResourceKey, out Sprite[] idle, out error)
|
||||
|| !TryLoadOptional(catalogEntry.WalkResourceKey, out Sprite[] walk, out error)
|
||||
|| !TryLoadOptional(catalogEntry.FlyingResourceKey, out Sprite[] flying, out error)
|
||||
|| !TryLoadOptional(catalogEntry.ProjectileResourceKey, out Sprite[] projectile, out error))
|
||||
return false;
|
||||
|
||||
if (idle.Length == 0 && flying.Length == 0)
|
||||
return Fail($"Source '{poolEntry.SourceCharacterId}' needs authored Idle or Flying frames.", out error);
|
||||
if (walk.Length == 0 && flying.Length == 0)
|
||||
return Fail($"Source '{poolEntry.SourceCharacterId}' needs authored Walk or Flying frames.", out error);
|
||||
|
||||
var attacks = new Dictionary<string, Sprite[]>(StringComparer.Ordinal);
|
||||
foreach (Defense25DMonsterSourceMotionAction actionEntry in catalogEntry.AttackActions)
|
||||
{
|
||||
if (actionEntry == null || string.IsNullOrWhiteSpace(actionEntry.ActionName))
|
||||
return Fail($"Source '{poolEntry.SourceCharacterId}' has an invalid attack action entry.", out error);
|
||||
if (!TryLoadRequired(actionEntry.ResourceKey, out Sprite[] actionFrames, out error))
|
||||
return false;
|
||||
if (!attacks.TryAdd(actionEntry.ActionName, actionFrames))
|
||||
return Fail($"Source '{poolEntry.SourceCharacterId}' repeats attack action '{actionEntry.ActionName}'.", out error);
|
||||
}
|
||||
|
||||
if (!attacks.TryGetValue(requiredAction, out Sprite[] exactAttack) || exactAttack.Length == 0)
|
||||
return Fail($"Source '{poolEntry.SourceCharacterId}' did not load its exact '{requiredAction}' frames.", out error);
|
||||
|
||||
motion = new Defense25DMonsterSourceMotion(poolEntry.SourceCharacterId,
|
||||
idle, walk, attacks, flying, catalogEntry.FrameSeconds, projectile);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryValidateAgainst(Defense25DMonsterPoolDefinition pool, out string error)
|
||||
{
|
||||
if (pool == null)
|
||||
return Fail("MonsterPoolDefinition is required.", out error);
|
||||
if (!pool.TryValidate(out error)) return false;
|
||||
|
||||
int poolCount = 0;
|
||||
foreach (Defense25DMonsterTierPool tier in pool.Tiers) poolCount += tier.Entries.Count;
|
||||
if (Entries.Count != poolCount)
|
||||
return Fail($"Source-motion catalog has {Entries.Count} entries; pool has {poolCount}.", out error);
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (Defense25DMonsterSourceMotionEntry entry in Entries)
|
||||
{
|
||||
if (entry == null || string.IsNullOrWhiteSpace(entry.EnemyDefinitionId)
|
||||
|| string.IsNullOrWhiteSpace(entry.SourceCharacterId))
|
||||
return Fail("Source-motion catalog contains an entry without exact pool identity.", out error);
|
||||
if (!seen.Add(entry.EnemyDefinitionId))
|
||||
return Fail($"Source-motion catalog repeats '{entry.EnemyDefinitionId}'.", out error);
|
||||
}
|
||||
|
||||
foreach (Defense25DMonsterTierPool tier in pool.Tiers)
|
||||
foreach (Defense25DMonsterPoolEntry poolEntry in tier.Entries)
|
||||
{
|
||||
if (!TryResolve(poolEntry, out _, out error)) return false;
|
||||
}
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private Defense25DMonsterSourceMotionEntry FindEntry(string enemyDefinitionId,
|
||||
string sourceCharacterId)
|
||||
{
|
||||
foreach (Defense25DMonsterSourceMotionEntry entry in Entries)
|
||||
{
|
||||
if (entry != null
|
||||
&& string.Equals(entry.EnemyDefinitionId, enemyDefinitionId, StringComparison.Ordinal)
|
||||
&& string.Equals(entry.SourceCharacterId, sourceCharacterId, StringComparison.Ordinal))
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool TryLoadOptional(string resourceKey, out Sprite[] frames, out string error)
|
||||
{
|
||||
frames = Array.Empty<Sprite>();
|
||||
if (string.IsNullOrEmpty(resourceKey))
|
||||
{
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
return TryLoadRequired(resourceKey, out frames, out error);
|
||||
}
|
||||
|
||||
private bool TryLoadRequired(string resourceKey, out Sprite[] frames, out string error)
|
||||
{
|
||||
frames = Array.Empty<Sprite>();
|
||||
if (!IsResourcesRelativeKey(resourceKey))
|
||||
return Fail($"'{resourceKey}' must be a Resources-relative sprite key without an extension.", out error);
|
||||
|
||||
loadedFrames ??= new Dictionary<string, Sprite[]>(StringComparer.Ordinal);
|
||||
if (!loadedFrames.TryGetValue(resourceKey, out Sprite[] loaded))
|
||||
{
|
||||
// Do not use DefenseTowerArt.Load here: it prepends DefenseTowers-v1/.
|
||||
loaded = Resources.LoadAll<Sprite>(resourceKey);
|
||||
Array.Sort(loaded, CompareFrameNames);
|
||||
loadedFrames[resourceKey] = loaded;
|
||||
}
|
||||
|
||||
if (loaded == null || loaded.Length == 0)
|
||||
return Fail($"No imported Sprite frames loaded from Resources key '{resourceKey}'.", out error);
|
||||
for (int i = 0; i < loaded.Length; i++)
|
||||
if (loaded[i] == null)
|
||||
return Fail($"Resources key '{resourceKey}' contains a null Sprite frame.", out error);
|
||||
|
||||
frames = (Sprite[])loaded.Clone();
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsResourcesRelativeKey(string key) =>
|
||||
!string.IsNullOrWhiteSpace(key)
|
||||
&& !key.StartsWith("Assets/", StringComparison.Ordinal)
|
||||
&& !key.StartsWith("Resources/", StringComparison.Ordinal)
|
||||
&& !key.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
|
||||
&& key.IndexOf('\\') < 0;
|
||||
|
||||
private static int CompareFrameNames(Sprite left, Sprite right)
|
||||
{
|
||||
int index = FrameIndex(left.name).CompareTo(FrameIndex(right.name));
|
||||
return index != 0 ? index : string.Compare(left.name, right.name, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int FrameIndex(string name)
|
||||
{
|
||||
int separator = name.LastIndexOf('_');
|
||||
return separator >= 0 && int.TryParse(name.Substring(separator + 1), out int index)
|
||||
? index : 0;
|
||||
}
|
||||
|
||||
private static bool IsFinitePositive(float value) =>
|
||||
value > 0f && !float.IsNaN(value) && !float.IsInfinity(value);
|
||||
|
||||
private static bool Fail(string message, out string error)
|
||||
{
|
||||
error = message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class Defense25DMonsterSourceMotionEntry
|
||||
{
|
||||
[SerializeField] private string enemyDefinitionId;
|
||||
[SerializeField] private string sourceCharacterId;
|
||||
[SerializeField, Min(0.001f)] private float frameSeconds = .125f;
|
||||
[SerializeField] private string idleResourceKey;
|
||||
[SerializeField] private string walkResourceKey;
|
||||
[SerializeField] private string flyingResourceKey;
|
||||
[SerializeField] private string projectileResourceKey;
|
||||
[SerializeField] private Defense25DMonsterSourceMotionAction[] attackActions =
|
||||
Array.Empty<Defense25DMonsterSourceMotionAction>();
|
||||
|
||||
public string EnemyDefinitionId => enemyDefinitionId;
|
||||
public string SourceCharacterId => sourceCharacterId;
|
||||
public float FrameSeconds => frameSeconds;
|
||||
public string IdleResourceKey => idleResourceKey;
|
||||
public string WalkResourceKey => walkResourceKey;
|
||||
public string FlyingResourceKey => flyingResourceKey;
|
||||
public string ProjectileResourceKey => projectileResourceKey;
|
||||
public IReadOnlyList<Defense25DMonsterSourceMotionAction> AttackActions =>
|
||||
attackActions ?? Array.Empty<Defense25DMonsterSourceMotionAction>();
|
||||
|
||||
public bool TryFindAttack(string actionName, out Defense25DMonsterSourceMotionAction action)
|
||||
{
|
||||
foreach (Defense25DMonsterSourceMotionAction candidate in AttackActions)
|
||||
{
|
||||
if (candidate != null && string.Equals(candidate.ActionName, actionName, StringComparison.Ordinal))
|
||||
{
|
||||
action = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
action = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class Defense25DMonsterSourceMotionAction
|
||||
{
|
||||
[SerializeField] private string actionName;
|
||||
[SerializeField] private string resourceKey;
|
||||
|
||||
public string ActionName => actionName;
|
||||
public string ResourceKey => resourceKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5179496aa83a40d1af982574a9217310
|
||||
@@ -0,0 +1,321 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using BumpCombat.Defense;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
public enum Defense25DRewardSource
|
||||
{
|
||||
LevelUp = 0,
|
||||
GlobalElite = 1,
|
||||
GlobalMidBoss = 2,
|
||||
PersonalMissionRelic = 3,
|
||||
}
|
||||
|
||||
public enum Defense25DRewardTargetKind
|
||||
{
|
||||
PlayerModifier = 0,
|
||||
TowerModifier = 1,
|
||||
PersonalMissionRelic = 2,
|
||||
Currency = 3,
|
||||
}
|
||||
|
||||
public enum Defense25DRewardRarity
|
||||
{
|
||||
None = 0,
|
||||
Common = 1,
|
||||
Rare = 2,
|
||||
Epic = 3,
|
||||
}
|
||||
|
||||
public enum Defense25DTowerModifierEffect
|
||||
{
|
||||
AttackDamageIncreased = 0,
|
||||
AreaRadiusIncreased = 1,
|
||||
ChainCount = 2,
|
||||
AdditionalProjectile = 3,
|
||||
AdditionalTarget = 4,
|
||||
SplitProjectile = 5,
|
||||
ReturnProjectile = 6,
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct Defense25DRewardRarityWeights
|
||||
{
|
||||
[Min(0)] public int Common;
|
||||
[Min(0)] public int Rare;
|
||||
[Min(0)] public int Epic;
|
||||
|
||||
public int Total => Common + Rare + Epic;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class Defense25DTowerModifierDefinition
|
||||
{
|
||||
public string DefinitionId;
|
||||
public string DisplayName;
|
||||
[TextArea] public string Description;
|
||||
public DefenseTowerKind Family;
|
||||
public Defense25DTowerModifierEffect Effect;
|
||||
public Defense25DRewardRarity Rarity;
|
||||
[Min(0f)] public float Value;
|
||||
[Min(1)] public int MaximumStacks = 1;
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "BumpCombat/Constants/Defense 2.5D Rewards")]
|
||||
public sealed class Defense25DRewardConstants : ScriptableObject
|
||||
{
|
||||
[Min(0)] public int RunSeed = 1;
|
||||
[Min(1)] public int ChoiceCount = 3;
|
||||
public Defense25DRewardRarityWeights LevelUpRarityWeights =
|
||||
new() { Common = 75, Rare = 20, Epic = 5 };
|
||||
public Defense25DRewardRarityWeights GlobalEliteRarityWeights =
|
||||
new() { Common = 60, Rare = 30, Epic = 10 };
|
||||
[Min(0f)] public float PlayerRareEffectMultiplier = 1.5f;
|
||||
[Min(0f)] public float PlayerEpicEffectMultiplier = 2f;
|
||||
[Min(0)] public int EmptyEpicFallbackCurrency = 10;
|
||||
public Defense25DTowerModifierDefinition[] TowerModifierCatalog =
|
||||
CreateFirstTowerModifierCatalog();
|
||||
|
||||
private const string ChoiceConfirmationText = "선택하면 즉시 적용됩니다.";
|
||||
|
||||
public bool Validate(out string error)
|
||||
{
|
||||
if (ChoiceCount < 1 || !IsFinite(PlayerRareEffectMultiplier)
|
||||
|| PlayerRareEffectMultiplier <= 0f
|
||||
|| !IsFinite(PlayerEpicEffectMultiplier)
|
||||
|| PlayerEpicEffectMultiplier <= 0f
|
||||
|| EmptyEpicFallbackCurrency < 0)
|
||||
{
|
||||
error = "Reward choice count, player rarity multipliers, or currency fallback is invalid.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ValidWeights(LevelUpRarityWeights)
|
||||
|| !ValidWeights(GlobalEliteRarityWeights))
|
||||
{
|
||||
error = "Level-up and Global Elite rarity weights must be non-negative and total 100.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TowerModifierCatalog == null)
|
||||
{
|
||||
error = "Tower modifier catalog cannot be null.";
|
||||
return false;
|
||||
}
|
||||
|
||||
HashSet<string> definitionIds = new(StringComparer.Ordinal);
|
||||
Dictionary<string, int> stackCaps = new(StringComparer.Ordinal);
|
||||
foreach (Defense25DTowerModifierDefinition definition in TowerModifierCatalog)
|
||||
{
|
||||
if (definition == null
|
||||
|| string.IsNullOrWhiteSpace(definition.DefinitionId)
|
||||
|| string.IsNullOrWhiteSpace(definition.DisplayName)
|
||||
|| !IsFinite(definition.Value)
|
||||
|| definition.Value <= 0f
|
||||
|| definition.MaximumStacks < 1
|
||||
|| !definitionIds.Add(definition.DefinitionId))
|
||||
{
|
||||
error = "Tower modifier entries need unique IDs, display names, positive finite values, and positive stack caps.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsValidFamilyEffect(definition))
|
||||
{
|
||||
error = $"Tower modifier '{definition.DefinitionId}' has an unsupported family/effect/rarity combination.";
|
||||
return false;
|
||||
}
|
||||
|
||||
string stackKey = StackKey(definition.Family, definition.Effect);
|
||||
if (stackCaps.TryGetValue(stackKey, out int existingCap)
|
||||
&& existingCap != definition.MaximumStacks)
|
||||
{
|
||||
error = $"Tower modifier variants in '{stackKey}' must share one stack cap.";
|
||||
return false;
|
||||
}
|
||||
stackCaps[stackKey] = definition.MaximumStacks;
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static string StackKey(
|
||||
DefenseTowerKind family,
|
||||
Defense25DTowerModifierEffect effect)
|
||||
{
|
||||
return $"{(int)family}:{(int)effect}";
|
||||
}
|
||||
|
||||
private static bool ValidWeights(Defense25DRewardRarityWeights weights)
|
||||
{
|
||||
return weights.Common >= 0 && weights.Rare >= 0
|
||||
&& weights.Epic >= 0 && weights.Total == 100;
|
||||
}
|
||||
|
||||
private static bool IsValidFamilyEffect(
|
||||
Defense25DTowerModifierDefinition definition)
|
||||
{
|
||||
bool tieredRarity = definition.Rarity is Defense25DRewardRarity.Common
|
||||
or Defense25DRewardRarity.Rare
|
||||
or Defense25DRewardRarity.Epic;
|
||||
bool isEpic = definition.Rarity == Defense25DRewardRarity.Epic;
|
||||
return definition.Effect switch
|
||||
{
|
||||
Defense25DTowerModifierEffect.AttackDamageIncreased =>
|
||||
definition.Family != DefenseTowerKind.Empowering
|
||||
&& tieredRarity,
|
||||
Defense25DTowerModifierEffect.AreaRadiusIncreased =>
|
||||
definition.Family is DefenseTowerKind.Direct
|
||||
or DefenseTowerKind.Summon
|
||||
or DefenseTowerKind.Cold
|
||||
or DefenseTowerKind.Seismic
|
||||
or DefenseTowerKind.Empowering
|
||||
&& tieredRarity,
|
||||
Defense25DTowerModifierEffect.ChainCount =>
|
||||
definition.Family == DefenseTowerKind.Lightning
|
||||
&& definition.Rarity == Defense25DRewardRarity.Rare,
|
||||
Defense25DTowerModifierEffect.AdditionalProjectile =>
|
||||
definition.Family == DefenseTowerKind.Cold && isEpic,
|
||||
Defense25DTowerModifierEffect.AdditionalTarget =>
|
||||
definition.Family == DefenseTowerKind.Direct && isEpic,
|
||||
Defense25DTowerModifierEffect.SplitProjectile or
|
||||
Defense25DTowerModifierEffect.ReturnProjectile =>
|
||||
definition.Family == DefenseTowerKind.Cold && isEpic,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static Defense25DTowerModifierDefinition[] CreateFirstTowerModifierCatalog()
|
||||
{
|
||||
List<Defense25DTowerModifierDefinition> definitions = new();
|
||||
AddTiered(definitions, DefenseTowerKind.Direct, "fire", true);
|
||||
AddTiered(definitions, DefenseTowerKind.Summon, "summon", false);
|
||||
AddTiered(definitions, DefenseTowerKind.Lightning, "lightning", false);
|
||||
AddTiered(definitions, DefenseTowerKind.Cold, "cold", true);
|
||||
AddTiered(definitions, DefenseTowerKind.Seismic, "seismic", true);
|
||||
AddTiered(definitions, DefenseTowerKind.Empowering, "empowering", true,
|
||||
includeAttack: false);
|
||||
definitions.Add(Definition("lightning.chain.rare", "번개 연쇄 강화",
|
||||
$"연쇄 대상 +1명. {ChoiceConfirmationText}", DefenseTowerKind.Lightning,
|
||||
Defense25DTowerModifierEffect.ChainCount,
|
||||
Defense25DRewardRarity.Rare, 1f, 3));
|
||||
definitions.Add(Definition("cold.projectile.epic", "냉기 투사체 추가 발사",
|
||||
$"추가 투사체 +1발. {ChoiceConfirmationText}",
|
||||
DefenseTowerKind.Cold,
|
||||
Defense25DTowerModifierEffect.AdditionalProjectile,
|
||||
Defense25DRewardRarity.Epic, 1f, 2));
|
||||
definitions.Add(Definition("fire.targets.epic", "화염 동시 타격 강화",
|
||||
$"동시 타격 대상 +1개체. {ChoiceConfirmationText}", DefenseTowerKind.Direct,
|
||||
Defense25DTowerModifierEffect.AdditionalTarget,
|
||||
Defense25DRewardRarity.Epic, 1f, 1));
|
||||
definitions.Add(Definition("cold.split.epic", "냉기 투사체 분할",
|
||||
$"원본 최초 유효 적중 시 ±25° 방향으로 자식 투사체 2개를 발사합니다. 자식은 재분할하거나 복귀하지 않으며, 각 자식 피해는 원본 피해의 50%입니다. {ChoiceConfirmationText}",
|
||||
DefenseTowerKind.Cold,
|
||||
Defense25DTowerModifierEffect.SplitProjectile,
|
||||
Defense25DRewardRarity.Epic, 1f, 1));
|
||||
definitions.Add(Definition("cold.return.epic", "냉기 투사체 복귀",
|
||||
$"원본 비행 종료 후 발사 위치로 1회 복귀합니다. 복귀 피해는 원본의 50%입니다. {ChoiceConfirmationText}",
|
||||
DefenseTowerKind.Cold,
|
||||
Defense25DTowerModifierEffect.ReturnProjectile,
|
||||
Defense25DRewardRarity.Epic, 1f, 1));
|
||||
return definitions.ToArray();
|
||||
}
|
||||
|
||||
private static void AddTiered(
|
||||
List<Defense25DTowerModifierDefinition> definitions,
|
||||
DefenseTowerKind family,
|
||||
string familyId,
|
||||
bool includeArea,
|
||||
bool includeAttack = true)
|
||||
{
|
||||
string familyName = GetTowerFamilyDisplayName(family);
|
||||
if (includeAttack)
|
||||
{
|
||||
definitions.Add(Definition($"{familyId}.attack.common",
|
||||
$"{familyName} 공격 강화",
|
||||
$"공격 피해 +{FormatPercent(.10f)}% 증가. {ChoiceConfirmationText}", family,
|
||||
Defense25DTowerModifierEffect.AttackDamageIncreased,
|
||||
Defense25DRewardRarity.Common, .10f, 5));
|
||||
definitions.Add(Definition($"{familyId}.attack.rare",
|
||||
$"상급 {familyName} 공격 강화",
|
||||
$"공격 피해 +{FormatPercent(.20f)}% 증가. {ChoiceConfirmationText}", family,
|
||||
Defense25DTowerModifierEffect.AttackDamageIncreased,
|
||||
Defense25DRewardRarity.Rare, .20f, 5));
|
||||
definitions.Add(Definition($"{familyId}.attack.epic",
|
||||
$"최상급 {familyName} 공격 강화",
|
||||
$"공격 피해 +{FormatPercent(.35f)}% 증가. {ChoiceConfirmationText}", family,
|
||||
Defense25DTowerModifierEffect.AttackDamageIncreased,
|
||||
Defense25DRewardRarity.Epic, .35f, 5));
|
||||
}
|
||||
|
||||
if (includeArea)
|
||||
{
|
||||
definitions.Add(Definition($"{familyId}.area.common",
|
||||
$"{familyName} 범위 강화",
|
||||
$"효과 반경 +{FormatPercent(.10f)}% 증가. {ChoiceConfirmationText}", family,
|
||||
Defense25DTowerModifierEffect.AreaRadiusIncreased,
|
||||
Defense25DRewardRarity.Common, .10f, 3));
|
||||
definitions.Add(Definition($"{familyId}.area.rare",
|
||||
$"상급 {familyName} 범위 강화",
|
||||
$"효과 반경 +{FormatPercent(.15f)}% 증가. {ChoiceConfirmationText}", family,
|
||||
Defense25DTowerModifierEffect.AreaRadiusIncreased,
|
||||
Defense25DRewardRarity.Rare, .15f, 3));
|
||||
definitions.Add(Definition($"{familyId}.area.epic",
|
||||
$"최상급 {familyName} 범위 강화",
|
||||
$"효과 반경 +{FormatPercent(.25f)}% 증가. {ChoiceConfirmationText}", family,
|
||||
Defense25DTowerModifierEffect.AreaRadiusIncreased,
|
||||
Defense25DRewardRarity.Epic, .25f, 3));
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetTowerFamilyDisplayName(DefenseTowerKind family)
|
||||
{
|
||||
return family switch
|
||||
{
|
||||
DefenseTowerKind.Direct => "화염 포탑",
|
||||
DefenseTowerKind.Summon => "소환 포탑",
|
||||
DefenseTowerKind.Lightning => "번개 포탑",
|
||||
DefenseTowerKind.Cold => "냉기 포탑",
|
||||
DefenseTowerKind.Seismic => "지진 포탑",
|
||||
DefenseTowerKind.Empowering => "지원 포탑",
|
||||
_ => "포탑",
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatPercent(float value)
|
||||
{
|
||||
return (value * 100f).ToString("0.##", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static Defense25DTowerModifierDefinition Definition(
|
||||
string id,
|
||||
string displayName,
|
||||
string description,
|
||||
DefenseTowerKind family,
|
||||
Defense25DTowerModifierEffect effect,
|
||||
Defense25DRewardRarity rarity,
|
||||
float value,
|
||||
int maximumStacks)
|
||||
{
|
||||
return new Defense25DTowerModifierDefinition
|
||||
{
|
||||
DefinitionId = id,
|
||||
DisplayName = displayName,
|
||||
Description = description,
|
||||
Family = family,
|
||||
Effect = effect,
|
||||
Rarity = rarity,
|
||||
Value = value,
|
||||
MaximumStacks = maximumStacks,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsFinite(float value)
|
||||
{
|
||||
return !float.IsNaN(value) && !float.IsInfinity(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e31aa346c8342ebbba979968c251b40
|
||||
@@ -0,0 +1,352 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Enemies;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
[Serializable]
|
||||
public sealed class Defense25DBattlefieldDefinition
|
||||
{
|
||||
public string Id = "open-field";
|
||||
public Rect Bounds = new(-40f, -40f, 80f, 80f);
|
||||
public Vector2[] HomeCandidates = CreateHomeCandidates();
|
||||
[Min(0f)] public float MinBranchClearance = 28f;
|
||||
public float[] BranchRotationCandidates = { 0f, 60f, 120f, 180f, 240f, 300f };
|
||||
public Defense25DLocalRoute[] LocalRoutes = CreateLocalRoutes();
|
||||
public Vector2[] LocalPads = CreateLocalPads();
|
||||
|
||||
public static Defense25DBattlefieldDefinition CreateDefault() => new();
|
||||
|
||||
public bool TryCreateLayout(int layoutSeed, out Defense25DRunLayout layout, out string error)
|
||||
{
|
||||
layout = null;
|
||||
if (Bounds.width <= 0f || Bounds.height <= 0f)
|
||||
{
|
||||
error = $"Battlefield '{Id}' has invalid bounds.";
|
||||
return false;
|
||||
}
|
||||
if (HomeCandidates == null || HomeCandidates.Length == 0 ||
|
||||
BranchRotationCandidates == null || BranchRotationCandidates.Length == 0 ||
|
||||
LocalRoutes == null || LocalRoutes.Length != 12 || LocalPads == null || LocalPads.Length != 18)
|
||||
{
|
||||
error = $"Battlefield '{Id}' must define home/rotation candidates, 12 routes and 18 pads.";
|
||||
return false;
|
||||
}
|
||||
|
||||
List<Vector2> validHomes = new();
|
||||
foreach (Vector2 candidate in HomeCandidates)
|
||||
{
|
||||
if (candidate == Vector2.zero || !ContainsClearance(candidate))
|
||||
continue;
|
||||
if (!AllLayoutPointsFit(candidate, 0f))
|
||||
continue;
|
||||
validHomes.Add(candidate);
|
||||
}
|
||||
if (validHomes.Count == 0)
|
||||
{
|
||||
error = $"Battlefield '{Id}' has no home candidate with {MinBranchClearance:0.##} units of branch clearance.";
|
||||
return false;
|
||||
}
|
||||
|
||||
Defense25DDeterministicRandom random = new(layoutSeed, 0x4C41594F);
|
||||
Vector2 home = validHomes[random.NextInt(validHomes.Count)];
|
||||
float rotation = BranchRotationCandidates[random.NextInt(BranchRotationCandidates.Length)];
|
||||
if (!AllLayoutPointsFit(home, rotation))
|
||||
{
|
||||
error = $"Battlefield '{Id}' selected a layout point outside its bounds.";
|
||||
return false;
|
||||
}
|
||||
|
||||
layout = Defense25DRunLayout.Create(this, layoutSeed, home, rotation);
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool ContainsClearance(Vector2 home)
|
||||
{
|
||||
float clearance = Mathf.Max(0f, MinBranchClearance);
|
||||
return home.x - Bounds.xMin >= clearance && Bounds.xMax - home.x >= clearance &&
|
||||
home.y - Bounds.yMin >= clearance && Bounds.yMax - home.y >= clearance;
|
||||
}
|
||||
|
||||
private bool AllLayoutPointsFit(Vector2 home, float rotation)
|
||||
{
|
||||
foreach (Defense25DLocalRoute route in LocalRoutes)
|
||||
{
|
||||
if (route.Points == null || route.Points.Length < 2)
|
||||
return false;
|
||||
foreach (Vector2 point in route.Points)
|
||||
if (!Bounds.Contains(home + Rotate(point, rotation)))
|
||||
return false;
|
||||
}
|
||||
foreach (Vector2 pad in LocalPads)
|
||||
if (!Bounds.Contains(home + Rotate(pad, rotation)))
|
||||
return false;
|
||||
return Bounds.Contains(home + Rotate(new Vector2(0f, -2f), rotation));
|
||||
}
|
||||
|
||||
internal static Vector2 Rotate(Vector2 value, float degrees)
|
||||
{
|
||||
float radians = degrees * Mathf.Deg2Rad;
|
||||
float sine = Mathf.Sin(radians);
|
||||
float cosine = Mathf.Cos(radians);
|
||||
return new Vector2(value.x * cosine - value.y * sine, value.x * sine + value.y * cosine);
|
||||
}
|
||||
|
||||
private static Vector2[] CreateHomeCandidates()
|
||||
{
|
||||
List<Vector2> candidates = new(120);
|
||||
for (int x = -10; x <= 10; x += 2)
|
||||
for (int z = -10; z <= 10; z += 2)
|
||||
{
|
||||
Vector2 candidate = new(x, z);
|
||||
if (candidate != Vector2.zero)
|
||||
candidates.Add(candidate);
|
||||
}
|
||||
return candidates.ToArray();
|
||||
}
|
||||
|
||||
private static Defense25DLocalRoute[] CreateLocalRoutes()
|
||||
{
|
||||
int[] branchOrder = { 0, 3, 1, 4, 2, 5 };
|
||||
Defense25DLocalRoute[] routes = new Defense25DLocalRoute[12];
|
||||
for (int portalId = 0; portalId < routes.Length; portalId++)
|
||||
{
|
||||
int branch = branchOrder[portalId % 6];
|
||||
float angle = branch * 60f;
|
||||
float portalAngle = angle + (portalId < 6 ? -12f : 12f);
|
||||
Vector2 endpoint = Direction(portalAngle) * 24f;
|
||||
Vector2 branchOuter = Direction(angle) * 16f;
|
||||
Vector2 branchInner = Direction(angle) * 8f;
|
||||
routes[portalId] = new Defense25DLocalRoute(portalId,
|
||||
new[] { endpoint, branchOuter, branchInner, Vector2.zero });
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static Vector2[] CreateLocalPads()
|
||||
{
|
||||
Vector2[] pads = new Vector2[18];
|
||||
for (int branch = 0; branch < 6; branch++)
|
||||
{
|
||||
float angle = branch * 60f;
|
||||
int offset = branch * 3;
|
||||
pads[offset] = Direction(angle - 20f) * 12f;
|
||||
pads[offset + 1] = Direction(angle + 20f) * 12f;
|
||||
pads[offset + 2] = Direction(angle + 30f) * 6f;
|
||||
}
|
||||
return pads;
|
||||
}
|
||||
|
||||
private static Vector2 Direction(float degrees)
|
||||
{
|
||||
float radians = degrees * Mathf.Deg2Rad;
|
||||
return new Vector2(Mathf.Cos(radians), Mathf.Sin(radians));
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct Defense25DLocalRoute
|
||||
{
|
||||
public int PortalId;
|
||||
public Vector2[] Points;
|
||||
|
||||
public Defense25DLocalRoute(int portalId, Vector2[] points)
|
||||
{
|
||||
PortalId = portalId;
|
||||
Points = points;
|
||||
}
|
||||
}
|
||||
|
||||
public enum Defense25DTowerKindId
|
||||
{
|
||||
None = 0,
|
||||
Fire = 1,
|
||||
Summon = 2,
|
||||
Lightning = 3,
|
||||
Cold = 4,
|
||||
Seismic = 5,
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct Defense25DPortalDefinition
|
||||
{
|
||||
public int PortalId;
|
||||
[Min(0f)] public float OpenAt;
|
||||
[Min(0f)] public float TelegraphSeconds;
|
||||
public int RouteId;
|
||||
public bool HasDamageImmunity;
|
||||
public Defense25DTowerKindId ImmuneTowerKindId;
|
||||
|
||||
public static Defense25DPortalDefinition Create(int portalId, float openAt, float telegraphSeconds,
|
||||
int routeId, bool hasDamageImmunity, Defense25DTowerKindId immuneTowerKindId) => new()
|
||||
{
|
||||
PortalId = portalId,
|
||||
OpenAt = openAt,
|
||||
TelegraphSeconds = telegraphSeconds,
|
||||
RouteId = routeId,
|
||||
HasDamageImmunity = hasDamageImmunity,
|
||||
ImmuneTowerKindId = immuneTowerKindId,
|
||||
};
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "BumpCombat/Defense/2.5D Long Defense Run")]
|
||||
public sealed class Defense25DRunDefinition : ScriptableObject
|
||||
{
|
||||
public string ProfileId = "LongDefense-v2";
|
||||
[Min(0f)] public float PreparationSeconds = 20f;
|
||||
[Min(0f)] public float FinalBossAtSeconds = 720f;
|
||||
public Defense25DBattlefieldDefinition[] BattlefieldDefinitions =
|
||||
{ Defense25DBattlefieldDefinition.CreateDefault() };
|
||||
public Defense25DPortalDefinition[] PortalDefinitions = CreateDefaultPortals();
|
||||
public Defense25DWaveDirectorConstants WaveDirectorConstants;
|
||||
public Defense25DMonsterPoolDefinition MonsterPoolDefinition;
|
||||
public Defense25DMonsterSourceMotionCatalog MonsterSourceMotionCatalog;
|
||||
public int[] PersonalMissionRequiredPortalCounts = { 3, 6, 3 };
|
||||
public Defense25DArtifactDropConstants ArtifactDropConstants;
|
||||
public Defense25DRewardConstants RewardConstants;
|
||||
public Defense25DFacilityVisualConstants FacilityVisualConstants;
|
||||
public EnemyDefinition GlobalEliteDefinition;
|
||||
public EnemyDefinition GlobalMidBossDefinition;
|
||||
public EnemyDefinition FinalBossDefinition;
|
||||
public GameObject FinalBossVisualPrefab;
|
||||
|
||||
public bool Validate(out string error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ProfileId) || !IsFinite(PreparationSeconds) || PreparationSeconds < 0f ||
|
||||
!IsFinite(FinalBossAtSeconds) || FinalBossAtSeconds < 0f ||
|
||||
BattlefieldDefinitions == null || BattlefieldDefinitions.Length == 0 || WaveDirectorConstants == null ||
|
||||
MonsterPoolDefinition == null || MonsterSourceMotionCatalog == null || ArtifactDropConstants == null ||
|
||||
RewardConstants == null || FacilityVisualConstants == null || GlobalEliteDefinition == null || GlobalMidBossDefinition == null ||
|
||||
FinalBossDefinition == null || FinalBossVisualPrefab == null)
|
||||
{
|
||||
error = "The long-defense profile, preparation/final time, battlefield, wave constants, monster pool, source-motion catalog, special enemy definitions, final-boss visual source, and artifact/reward constants are required.";
|
||||
return false;
|
||||
}
|
||||
if (!WaveDirectorConstants.Validate(out error)) return false;
|
||||
if (!MonsterPoolDefinition.TryValidate(out error)) return false;
|
||||
if (!MonsterSourceMotionCatalog.TryValidateAgainst(MonsterPoolDefinition, out error)) return false;
|
||||
if (!ArtifactDropConstants.Validate(out error)) return false;
|
||||
if (!RewardConstants.Validate(out error)) return false;
|
||||
if (!FacilityVisualConstants.TryValidate(out error)) return false;
|
||||
if (!ValidatePortals(PortalDefinitions, FinalBossAtSeconds,
|
||||
BattlefieldDefinitions[0]?.LocalRoutes?.Length ?? 0,
|
||||
PersonalMissionRequiredPortalCounts, out error))
|
||||
return false;
|
||||
foreach (Defense25DBattlefieldDefinition battlefield in BattlefieldDefinitions)
|
||||
{
|
||||
if (battlefield == null || !battlefield.TryCreateLayout(0, out _, out error))
|
||||
return false;
|
||||
}
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ValidatePortals(Defense25DPortalDefinition[] portals, float finalBossAtSeconds,
|
||||
int routeCount, IReadOnlyList<int> missionPortalCounts, out string error)
|
||||
{
|
||||
if (!IsFinite(finalBossAtSeconds) || finalBossAtSeconds < 0f ||
|
||||
portals == null || portals.Length == 0 || routeCount <= 0)
|
||||
{
|
||||
error = "Portal definitions and their route definitions are required.";
|
||||
return false;
|
||||
}
|
||||
if (portals.Length != routeCount)
|
||||
{
|
||||
error = $"Every portal must own exactly one route ({portals.Length} portals, {routeCount} routes).";
|
||||
return false;
|
||||
}
|
||||
HashSet<int> ids = new();
|
||||
HashSet<int> routeIds = new();
|
||||
bool opensForFinalBoss = false;
|
||||
foreach (Defense25DPortalDefinition portal in portals)
|
||||
{
|
||||
if (!ids.Add(portal.PortalId) || portal.PortalId < 0 || !IsFinite(portal.OpenAt) || portal.OpenAt < 0f ||
|
||||
!IsFinite(portal.TelegraphSeconds) || portal.TelegraphSeconds < 0f ||
|
||||
portal.RouteId < 0 || portal.RouteId >= routeCount ||
|
||||
!Enum.IsDefined(typeof(Defense25DTowerKindId), portal.ImmuneTowerKindId) ||
|
||||
(portal.HasDamageImmunity == (portal.ImmuneTowerKindId == Defense25DTowerKindId.None)))
|
||||
{
|
||||
error = $"Portal {portal.PortalId} has a duplicate ID or invalid route, time or immunity kind.";
|
||||
return false;
|
||||
}
|
||||
if (!routeIds.Add(portal.RouteId))
|
||||
{
|
||||
error = $"Portal route {portal.RouteId} is assigned more than once.";
|
||||
return false;
|
||||
}
|
||||
opensForFinalBoss |= portal.OpenAt <= finalBossAtSeconds;
|
||||
}
|
||||
if (!opensForFinalBoss)
|
||||
{
|
||||
error = "At least one portal must be open when the final boss is scheduled.";
|
||||
return false;
|
||||
}
|
||||
if (missionPortalCounts != null)
|
||||
foreach (int required in missionPortalCounts)
|
||||
if (required < 0 || required > portals.Length)
|
||||
{
|
||||
error = $"A mission requires {required} portals, but only {portals.Length} are defined.";
|
||||
return false;
|
||||
}
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static Defense25DPortalDefinition[] CreateDefaultPortals()
|
||||
{
|
||||
float[] openTimes = { 0f, 0f, 120f, 120f, 240f, 240f, 360f, 360f, 480f, 480f, 600f, 600f };
|
||||
Defense25DTowerKindId[] immunityOrder =
|
||||
{
|
||||
Defense25DTowerKindId.Fire, Defense25DTowerKindId.Summon, Defense25DTowerKindId.Lightning,
|
||||
Defense25DTowerKindId.Cold, Defense25DTowerKindId.Seismic, Defense25DTowerKindId.None,
|
||||
Defense25DTowerKindId.Cold, Defense25DTowerKindId.Lightning, Defense25DTowerKindId.Summon,
|
||||
Defense25DTowerKindId.Fire, Defense25DTowerKindId.None, Defense25DTowerKindId.Seismic,
|
||||
};
|
||||
Defense25DPortalDefinition[] portals = new Defense25DPortalDefinition[openTimes.Length];
|
||||
for (int id = 0; id < portals.Length; id++)
|
||||
portals[id] = Defense25DPortalDefinition.Create(id, openTimes[id], 10f, id,
|
||||
immunityOrder[id] != Defense25DTowerKindId.None, immunityOrder[id]);
|
||||
return portals;
|
||||
}
|
||||
|
||||
private static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value);
|
||||
}
|
||||
|
||||
internal sealed class Defense25DDeterministicRandom
|
||||
{
|
||||
private uint state;
|
||||
|
||||
public Defense25DDeterministicRandom(int seed, uint stream)
|
||||
{
|
||||
uint value = unchecked((uint)seed) ^ (stream * 0x9E3779B9u) ^ 0xA511E9B3u;
|
||||
value ^= value >> 16;
|
||||
value *= 0x7FEB352Du;
|
||||
value ^= value >> 15;
|
||||
value *= 0x846CA68Bu;
|
||||
value ^= value >> 16;
|
||||
state = value == 0 ? 0x6D2B79F5u : value;
|
||||
}
|
||||
|
||||
public uint NextUInt()
|
||||
{
|
||||
uint value = state;
|
||||
value ^= value << 13;
|
||||
value ^= value >> 17;
|
||||
value ^= value << 5;
|
||||
state = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
public int NextInt(int exclusiveMaximum)
|
||||
{
|
||||
if (exclusiveMaximum <= 0) throw new ArgumentOutOfRangeException(nameof(exclusiveMaximum));
|
||||
return (int)(NextUInt() % (uint)exclusiveMaximum);
|
||||
}
|
||||
|
||||
public float NextFloat() => (NextUInt() >> 8) * (1f / 16777216f);
|
||||
public float Range(float minimum, float maximum) => minimum + (maximum - minimum) * NextFloat();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 04714b973bf84e0abe965fc9e56b7644
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,62 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BumpCombat/Constants/Defense 2.5D Run HUD")]
|
||||
public sealed class Defense25DRunHudConstants : ScriptableObject
|
||||
{
|
||||
public const float ReferenceWidth = 1920f;
|
||||
public const float ReferenceHeight = 1080f;
|
||||
|
||||
[Header("Combat status")]
|
||||
public Vector2 CombatHudBottomLeft = new(24f, 24f);
|
||||
public Vector2 CombatHudSize = new(392f, 136f);
|
||||
public Vector2 HealthTrackSize = new(352f, 12f);
|
||||
public Vector2 ArtifactGaugeSize = new(352f, 18f);
|
||||
public Vector2 ExperienceTrackSize = new(352f, 8f);
|
||||
public float TrackSpacing = 2f;
|
||||
public float ArtifactSlotSize = 60f;
|
||||
public float ArtifactSlotSpacing = 62f;
|
||||
public int MaximumArtifactSlots = 3;
|
||||
public Vector2 GuardTrackSize = new(18f, 94f);
|
||||
public Vector2 GuardFillSize = new(14f, 90f);
|
||||
public float GuardRightOffset = 32f;
|
||||
|
||||
[Header("Run status")]
|
||||
public Vector2 TimerSize = new(220f, 66f);
|
||||
public float TimerTop = 24f;
|
||||
public Vector2 WaveSummarySize = new(700f, 76f);
|
||||
public float WaveSummaryTop = 98f;
|
||||
|
||||
[Header("Map and portals")]
|
||||
public Vector2 MinimapSize = new(300f, 162f);
|
||||
public Vector2 MinimapContentSize = new(288f, 150f);
|
||||
public float MinimapRight = 24f;
|
||||
public float MinimapTop = 24f;
|
||||
public Vector2 PortalListSize = new(320f, 300f);
|
||||
public float PortalListRight = 24f;
|
||||
public float PortalListTop = 194f;
|
||||
public float PortalRowHeight = 24f;
|
||||
public int PortalCount = 12;
|
||||
public Vector2 MissionPanelSize = new(320f, 126f);
|
||||
public float MissionPanelRight = 24f;
|
||||
public float MissionPanelTop = 502f;
|
||||
public int MissionCount = 3;
|
||||
|
||||
[Header("Context panels")]
|
||||
public Vector2 ConstructionSize = new(360f, 300f);
|
||||
public float ConstructionLeft = 24f;
|
||||
public float ConstructionTop = 420f;
|
||||
public Vector2 ModalMaximumSize = new(1040f, 420f);
|
||||
public int HeaderFontSize = 24;
|
||||
public int BodyFontSize = 18;
|
||||
public int SmallFontSize = 14;
|
||||
|
||||
public static bool IsValidViewport(Rect viewport) =>
|
||||
viewport.width > 0f && viewport.height > 0f;
|
||||
|
||||
public static float ScaleForViewport(Rect viewport) => IsValidViewport(viewport)
|
||||
? Mathf.Min(viewport.width / ReferenceWidth, viewport.height / ReferenceHeight)
|
||||
: 0f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4a0a28d1a11445d685cb16c9c21a04be
|
||||
@@ -0,0 +1,270 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
[Serializable]
|
||||
public struct Defense25DThreatCurvePoint
|
||||
{
|
||||
public float EndTimeSeconds;
|
||||
public float ThreatBudget;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct Defense25DMonsterTierCurvePoint
|
||||
{
|
||||
public float EndTimeSeconds;
|
||||
public int TierId;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct Defense25DActiveNodeRangePoint
|
||||
{
|
||||
public float EndTimeSeconds;
|
||||
public int Minimum;
|
||||
public int Maximum;
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "BumpCombat/Constants/Defense 2.5D Wave Director")]
|
||||
public sealed class Defense25DWaveDirectorConstants : ScriptableObject
|
||||
{
|
||||
[Min(0.1f)] public float SpawnWindowSeconds = 18f;
|
||||
[Min(0f)] public float MicroBreakSeconds = 6f;
|
||||
[Min(1)] public int StrategyBreakEvery = 3;
|
||||
[Min(0f)] public float StrategyBreakSeconds = 15f;
|
||||
[Min(0f)] public float TelegraphSeconds = 8f;
|
||||
public Defense25DThreatCurvePoint[] ThreatCurve =
|
||||
{
|
||||
new() { EndTimeSeconds = 120f, ThreatBudget = 24f },
|
||||
new() { EndTimeSeconds = 240f, ThreatBudget = 36f },
|
||||
new() { EndTimeSeconds = 360f, ThreatBudget = 48f },
|
||||
new() { EndTimeSeconds = 480f, ThreatBudget = 60f },
|
||||
new() { EndTimeSeconds = 600f, ThreatBudget = 72f },
|
||||
new() { EndTimeSeconds = float.PositiveInfinity, ThreatBudget = 84f },
|
||||
};
|
||||
public Defense25DActiveNodeRangePoint[] ActiveNodeRangeCurve =
|
||||
{
|
||||
new() { EndTimeSeconds = 120f, Minimum = 1, Maximum = 2 },
|
||||
new() { EndTimeSeconds = 240f, Minimum = 2, Maximum = 3 },
|
||||
new() { EndTimeSeconds = 360f, Minimum = 2, Maximum = 4 },
|
||||
new() { EndTimeSeconds = 480f, Minimum = 3, Maximum = 5 },
|
||||
new() { EndTimeSeconds = 600f, Minimum = 4, Maximum = 6 },
|
||||
new() { EndTimeSeconds = float.PositiveInfinity, Minimum = 5, Maximum = 7 },
|
||||
};
|
||||
[Range(0f, 1f)] public Vector2 PrimaryNodeShare = new(0.35f, 0.50f);
|
||||
[Min(0f)] public float EliteFirstAtSeconds = 90f;
|
||||
[Min(0f)] public float EliteIntervalSeconds = 90f;
|
||||
[Min(0.001f)] public float StrongEventMinimumIntervalSeconds = 1f;
|
||||
public float[] MidBossTimes = { 240f, 480f };
|
||||
[Min(0f)] public float EarlyCallRewardSecondsPerCurrency = 3f;
|
||||
[Min(0)] public int EarlyCallRewardMaximum = 5;
|
||||
public Defense25DMonsterTierCurvePoint[] MonsterTierCurve =
|
||||
{
|
||||
new() { EndTimeSeconds = 180f, TierId = 0 },
|
||||
new() { EndTimeSeconds = 360f, TierId = 1 },
|
||||
new() { EndTimeSeconds = float.PositiveInfinity, TierId = 2 },
|
||||
};
|
||||
|
||||
public bool Validate(out string error)
|
||||
{
|
||||
if (!IsFinite(SpawnWindowSeconds) || SpawnWindowSeconds <= 0f ||
|
||||
!IsFinite(MicroBreakSeconds) || MicroBreakSeconds <= 0f || StrategyBreakEvery <= 0 ||
|
||||
!IsFinite(StrategyBreakSeconds) || StrategyBreakSeconds <= 0f ||
|
||||
!IsFinite(TelegraphSeconds) || TelegraphSeconds < 0f)
|
||||
{
|
||||
error = "Wave spawn window must be positive and break/telegraph durations non-negative and finite.";
|
||||
return false;
|
||||
}
|
||||
if (!ValidateThreatCurve(ThreatCurve, out error) ||
|
||||
!ValidateMonsterTierCurve(MonsterTierCurve, out error) ||
|
||||
!ValidateNodeCurve(ActiveNodeRangeCurve, out error))
|
||||
return false;
|
||||
if (!IsFinite(PrimaryNodeShare.x) || !IsFinite(PrimaryNodeShare.y) ||
|
||||
PrimaryNodeShare.x < 0f || PrimaryNodeShare.y > 1f || PrimaryNodeShare.x > PrimaryNodeShare.y)
|
||||
{
|
||||
error = "Primary Node share must be an ordered range inside 0..1.";
|
||||
return false;
|
||||
}
|
||||
if (!IsFinite(EliteFirstAtSeconds) || EliteFirstAtSeconds < 0f ||
|
||||
!IsFinite(EliteIntervalSeconds) || EliteIntervalSeconds <= 0f ||
|
||||
!IsFinite(StrongEventMinimumIntervalSeconds) || StrongEventMinimumIntervalSeconds <= 0f)
|
||||
{
|
||||
error = "Global Elite timing and the minimum strong-event interval must be positive and finite.";
|
||||
return false;
|
||||
}
|
||||
if (MidBossTimes == null)
|
||||
{
|
||||
error = "MidBoss times cannot be null.";
|
||||
return false;
|
||||
}
|
||||
float previousMidBoss = -1f;
|
||||
foreach (float time in MidBossTimes)
|
||||
{
|
||||
if (!IsFinite(time) || time < 0f || time <= previousMidBoss)
|
||||
{
|
||||
error = "MidBoss times must be non-negative, finite and strictly increasing.";
|
||||
return false;
|
||||
}
|
||||
previousMidBoss = time;
|
||||
}
|
||||
if (!IsFinite(EarlyCallRewardSecondsPerCurrency) || EarlyCallRewardSecondsPerCurrency <= 0f ||
|
||||
EarlyCallRewardMaximum < 0)
|
||||
{
|
||||
error = "Early Call reward settings are invalid.";
|
||||
return false;
|
||||
}
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetThreat(float nominalStart, out float budget)
|
||||
{
|
||||
if (IsFinite(nominalStart) && nominalStart >= 0f && ThreatCurve != null)
|
||||
{
|
||||
foreach (Defense25DThreatCurvePoint point in ThreatCurve)
|
||||
if (nominalStart < point.EndTimeSeconds)
|
||||
{
|
||||
budget = point.ThreatBudget;
|
||||
return IsFinite(budget) && budget > 0f;
|
||||
}
|
||||
}
|
||||
budget = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetMonsterTier(float nominalStart, out int tierId)
|
||||
{
|
||||
if (IsFinite(nominalStart) && nominalStart >= 0f && MonsterTierCurve != null)
|
||||
{
|
||||
foreach (Defense25DMonsterTierCurvePoint point in MonsterTierCurve)
|
||||
if (nominalStart < point.EndTimeSeconds)
|
||||
{
|
||||
tierId = point.TierId;
|
||||
return tierId >= 0 && tierId <= 2;
|
||||
}
|
||||
}
|
||||
tierId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetActiveNodeRange(float nominalStart, out int minimum, out int maximum)
|
||||
{
|
||||
if (IsFinite(nominalStart) && nominalStart >= 0f && ActiveNodeRangeCurve != null)
|
||||
{
|
||||
foreach (Defense25DActiveNodeRangePoint point in ActiveNodeRangeCurve)
|
||||
if (nominalStart < point.EndTimeSeconds)
|
||||
{
|
||||
minimum = point.Minimum;
|
||||
maximum = point.Maximum;
|
||||
return minimum > 0 && maximum >= minimum;
|
||||
}
|
||||
}
|
||||
minimum = maximum = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ValidateThreatCurve(Defense25DThreatCurvePoint[] curve, out string error)
|
||||
{
|
||||
if (curve == null || curve.Length == 0)
|
||||
{
|
||||
error = "Threat curve cannot be empty.";
|
||||
return false;
|
||||
}
|
||||
float previousEnd = 0f;
|
||||
for (int i = 0; i < curve.Length; i++)
|
||||
{
|
||||
Defense25DThreatCurvePoint point = curve[i];
|
||||
bool finalEnd = i == curve.Length - 1 && float.IsPositiveInfinity(point.EndTimeSeconds);
|
||||
if ((!finalEnd && (!IsFinite(point.EndTimeSeconds) || point.EndTimeSeconds <= previousEnd)) ||
|
||||
!IsFinite(point.ThreatBudget) || point.ThreatBudget <= 0f)
|
||||
{
|
||||
error = $"Threat curve point {i} has an invalid end time or budget.";
|
||||
return false;
|
||||
}
|
||||
if (i > 0 && point.EndTimeSeconds <= previousEnd)
|
||||
{
|
||||
error = "Threat curve end times must be strictly increasing.";
|
||||
return false;
|
||||
}
|
||||
previousEnd = point.EndTimeSeconds;
|
||||
}
|
||||
if (!float.IsPositiveInfinity(curve[curve.Length - 1].EndTimeSeconds))
|
||||
{
|
||||
error = "Threat curve must cover all future nominal wave times.";
|
||||
return false;
|
||||
}
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ValidateMonsterTierCurve(Defense25DMonsterTierCurvePoint[] curve, out string error)
|
||||
{
|
||||
if (curve == null || curve.Length == 0)
|
||||
{
|
||||
error = "Monster tier curve cannot be empty.";
|
||||
return false;
|
||||
}
|
||||
float previousEnd = 0f;
|
||||
for (int i = 0; i < curve.Length; i++)
|
||||
{
|
||||
Defense25DMonsterTierCurvePoint point = curve[i];
|
||||
bool finalEnd = i == curve.Length - 1 && float.IsPositiveInfinity(point.EndTimeSeconds);
|
||||
if ((!finalEnd && (!IsFinite(point.EndTimeSeconds) || point.EndTimeSeconds <= previousEnd)) ||
|
||||
point.TierId < 0 || point.TierId > 2)
|
||||
{
|
||||
error = $"Monster tier curve point {i} has an invalid end time or tier.";
|
||||
return false;
|
||||
}
|
||||
if (i > 0 && point.EndTimeSeconds <= previousEnd)
|
||||
{
|
||||
error = "Monster tier curve end times must be strictly increasing.";
|
||||
return false;
|
||||
}
|
||||
previousEnd = point.EndTimeSeconds;
|
||||
}
|
||||
if (!float.IsPositiveInfinity(curve[curve.Length - 1].EndTimeSeconds))
|
||||
{
|
||||
error = "Monster tier curve must cover all future nominal wave times.";
|
||||
return false;
|
||||
}
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ValidateNodeCurve(Defense25DActiveNodeRangePoint[] curve, out string error)
|
||||
{
|
||||
if (curve == null || curve.Length == 0)
|
||||
{
|
||||
error = "Active Node range curve cannot be empty.";
|
||||
return false;
|
||||
}
|
||||
float previousEnd = 0f;
|
||||
for (int i = 0; i < curve.Length; i++)
|
||||
{
|
||||
Defense25DActiveNodeRangePoint point = curve[i];
|
||||
bool finalEnd = i == curve.Length - 1 && float.IsPositiveInfinity(point.EndTimeSeconds);
|
||||
if ((!finalEnd && (!IsFinite(point.EndTimeSeconds) || point.EndTimeSeconds <= previousEnd)) ||
|
||||
point.Minimum <= 0 || point.Maximum < point.Minimum)
|
||||
{
|
||||
error = $"Active Node range point {i} has an invalid end time or range.";
|
||||
return false;
|
||||
}
|
||||
if (i > 0 && point.EndTimeSeconds <= previousEnd)
|
||||
{
|
||||
error = "Active Node range end times must be strictly increasing.";
|
||||
return false;
|
||||
}
|
||||
previousEnd = point.EndTimeSeconds;
|
||||
}
|
||||
if (!float.IsPositiveInfinity(curve[curve.Length - 1].EndTimeSeconds))
|
||||
{
|
||||
error = "Active Node range curve must cover all future nominal wave times.";
|
||||
return false;
|
||||
}
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5c8e29b708945b0b8c4da105e3fdfd0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user