513 lines
25 KiB
C#
513 lines
25 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|