2.5 update

This commit is contained in:
2026-09-28 11:07:17 +09:00
parent abd2da2057
commit 7978d82196
976 changed files with 111965 additions and 313 deletions
@@ -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: