Files
2026-09-28 11:07:17 +09:00

353 lines
15 KiB
C#

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();
}
}