Initial commit: OpenRA game engine
Some checks failed
Continuous Integration / Linux (.NET 8.0) (push) Has been cancelled
Continuous Integration / Windows (.NET 8.0) (push) Has been cancelled

Fork from OpenRA/OpenRA with one-click launch script (start-ra.cmd)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
let5sne.win10
2026-01-10 21:46:54 +08:00
commit 9cf6ebb986
4065 changed files with 635973 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* This file is part of OpenRA, which is free software. It is made
* available to you under the terms of the GNU General Public License
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version. For more
* information, see COPYING.
*/
#endregion
using System;
using System.Collections.Generic;
using System.Linq;
using OpenRA.Primitives;
using OpenRA.Traits;
namespace OpenRA
{
/// <summary>
/// A unit/building inside the game. Every rules starts with one and adds trait to it.
/// </summary>
public class ActorInfo
{
public const char AbstractActorPrefix = '^';
public const char TraitInstanceSeparator = '@';
/// <summary>
/// The actor name can be anything, but the sprites used in the Render*: traits default to this one.
/// If you add an ^ in front of the name, the engine will recognize this as a collection of traits
/// that can be inherited by others (using Inherits:) and not a real unit.
/// You can remove inherited traits by adding a - in front of them as in -TraitName: to inherit everything, but this trait.
/// </summary>
public readonly string Name;
readonly TypeDictionary traits = [];
TraitInfo[] constructOrderCache = null;
public ActorInfo(ObjectCreator creator, string name, MiniYaml node)
{
try
{
Name = name;
foreach (var t in node.Nodes)
{
try
{
// HACK: The linter does not want to crash when a trait doesn't exist but only print an error instead
// LoadTraitInfo will only return null to signal us to abort here if the linter is running
var trait = LoadTraitInfo(creator, t.Key, t.Value);
if (trait != null)
traits.Add(trait);
}
catch (FieldLoader.MissingFieldsException e)
{
throw new YamlException(e.Message);
}
}
traits.TrimExcess();
}
catch (YamlException e)
{
throw new YamlException($"Actor type {name}: {e.Message}");
}
}
public ActorInfo(string name, params TraitInfo[] traitInfos)
{
Name = name;
foreach (var t in traitInfos)
traits.Add(t);
traits.TrimExcess();
}
static TraitInfo LoadTraitInfo(ObjectCreator creator, string traitName, MiniYaml my)
{
if (!string.IsNullOrEmpty(my.Value))
throw new YamlException($"Junk value `{my.Value}` on trait node {traitName}");
// HACK: The linter does not want to crash when a trait doesn't exist but only print an error instead
// ObjectCreator will only return null to signal us to abort here if the linter is running
var traitInstance = traitName.Split(TraitInstanceSeparator);
var info = creator.CreateObject<TraitInfo>(traitInstance[0] + "Info");
if (info == null)
return null;
try
{
if (traitInstance.Length > 1)
info.GetType().GetField(nameof(info.InstanceName)).SetValue(info, traitInstance[1]);
FieldLoader.Load(info, my);
}
catch (FieldLoader.MissingFieldsException e)
{
var header = "Trait name " + traitName + ": " + (e.Missing.Length > 1 ? "Required properties missing" : "Required property missing");
throw new FieldLoader.MissingFieldsException(e.Missing, header);
}
return info;
}
public IEnumerable<TraitInfo> TraitsInConstructOrder()
{
if (constructOrderCache != null)
return constructOrderCache;
var source = traits.WithInterface<TraitInfo>().Select(i => new
{
Trait = i,
Type = i.GetType(),
Dependencies = PrerequisitesOf(i).ToList(),
OptionalDependencies = OptionalPrerequisitesOf(i).ToList()
}).ToList();
var resolved = source.Where(s => s.Dependencies.Count == 0 && s.OptionalDependencies.Count == 0).ToList();
var unresolved = source.ToHashSet();
unresolved.ExceptWith(resolved);
static bool AreResolvable(Type a, Type b) => a.IsAssignableFrom(b);
// This query detects which unresolved traits can be immediately resolved as all their direct dependencies are met.
var more = unresolved.Where(u =>
u.Dependencies.All(d => // To be resolvable, all dependencies must be satisfied according to the following conditions:
resolved.Exists(r => AreResolvable(d, r.Type)) && // There must exist a resolved trait that meets the dependency.
!unresolved.Any(u1 => AreResolvable(d, u1.Type))) && // All matching traits that meet this dependency must be resolved first.
u.OptionalDependencies.All(d => // To be resolvable, all optional dependencies must be satisfied according to the following condition:
!unresolved.Any(u1 => AreResolvable(d, u1.Type)))); // All matching traits that meet this optional dependencies must be resolved first.
// Continue resolving traits as long as possible.
// Each time we resolve some traits, this means dependencies for other traits may then be possible to satisfy in the next pass.
#pragma warning disable CA1851 // Possible multiple enumerations of 'IEnumerable' collection
var readyToResolve = more.ToList();
while (readyToResolve.Count != 0)
{
resolved.AddRange(readyToResolve);
unresolved.ExceptWith(readyToResolve);
readyToResolve.Clear();
readyToResolve.AddRange(more);
}
#pragma warning restore CA1851
if (unresolved.Count != 0)
{
var exceptionString = "ActorInfo(\"" + Name + "\") failed to initialize because of the following:\n";
var missing = unresolved.SelectMany(u => u.Dependencies.Where(d => !source.Any(s => AreResolvable(d, s.Type)))).Distinct();
exceptionString += "Missing:\n";
foreach (var m in missing)
exceptionString += m + " \n";
exceptionString += "Unresolved:\n";
foreach (var u in unresolved)
{
var deps = u.Dependencies.Where(d => !resolved.Exists(r => r.Type == d));
var optDeps = u.OptionalDependencies.Where(d => !resolved.Exists(r => r.Type == d));
var allDeps = string.Join(", ", deps.Select(o => o.ToString()).Concat(optDeps.Select(o => $"[{o}]")));
exceptionString += $"{u.Type}: {{ {allDeps} }}\n";
}
throw new YamlException(exceptionString);
}
constructOrderCache = resolved.Select(r => r.Trait).ToArray();
return constructOrderCache;
}
public static IEnumerable<Type> PrerequisitesOf(TraitInfo info)
{
return info
.GetType()
.GetInterfaces()
.Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Requires<>))
.Select(t => t.GetGenericArguments()[0]);
}
public static IEnumerable<Type> OptionalPrerequisitesOf(TraitInfo info)
{
return info
.GetType()
.GetInterfaces()
.Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(NotBefore<>))
.Select(t => t.GetGenericArguments()[0]);
}
public bool HasTraitInfo<T>() where T : ITraitInfoInterface { return traits.Contains<T>(); }
public T TraitInfo<T>() where T : ITraitInfoInterface { return traits.Get<T>(); }
public T TraitInfoOrDefault<T>() where T : ITraitInfoInterface { return traits.GetOrDefault<T>(); }
public IReadOnlyCollection<T> TraitInfos<T>() where T : ITraitInfoInterface { return traits.WithInterface<T>(); }
public BitSet<TargetableType> GetAllTargetTypes()
{
// PERF: Avoid LINQ.
var targetTypes = default(BitSet<TargetableType>);
foreach (var targetable in TraitInfos<ITargetableInfo>())
targetTypes = targetTypes.Union(targetable.GetTargetTypes());
return targetTypes;
}
}
}

View File

@@ -0,0 +1,65 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* This file is part of OpenRA, which is free software. It is made
* available to you under the terms of the GNU General Public License
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version. For more
* information, see COPYING.
*/
#endregion
using OpenRA.FileSystem;
namespace OpenRA.GameRules
{
public class MusicInfo
{
public readonly string Filename;
public readonly string Title;
public readonly bool Hidden;
public readonly float VolumeModifier = 1f;
public int Length { get; private set; } // seconds
public bool Exists { get; private set; }
public MusicInfo(string key, MiniYaml value)
{
Title = value.Value;
var nd = value.ToDictionary();
if (nd.TryGetValue("Hidden", out var yaml))
bool.TryParse(yaml.Value, out Hidden);
if (nd.TryGetValue("VolumeModifier", out yaml))
VolumeModifier = FieldLoader.GetValue<float>("VolumeModifier", yaml.Value);
var ext = nd.TryGetValue("Extension", out yaml) ? yaml.Value : "aud";
Filename = (nd.TryGetValue("Filename", out yaml) ? yaml.Value : key) + "." + ext;
}
public void Load(IReadOnlyFileSystem fileSystem)
{
if (!fileSystem.TryOpen(Filename, out var stream))
return;
try
{
Exists = true;
foreach (var loader in Game.ModData.SoundLoaders)
{
if (loader.TryParseSound(stream, out var soundFormat))
{
Length = (int)soundFormat.LengthInSeconds;
soundFormat.Dispose();
break;
}
}
}
finally
{
stream.Dispose();
}
}
}
}

View File

@@ -0,0 +1,281 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* This file is part of OpenRA, which is free software. It is made
* available to you under the terms of the GNU General Public License
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version. For more
* information, see COPYING.
*/
#endregion
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using OpenRA.FileSystem;
using OpenRA.GameRules;
using OpenRA.Traits;
namespace OpenRA
{
public class Ruleset
{
public readonly ActorInfoDictionary Actors;
public readonly IReadOnlyDictionary<string, WeaponInfo> Weapons;
public readonly IReadOnlyDictionary<string, SoundInfo> Voices;
public readonly IReadOnlyDictionary<string, SoundInfo> Notifications;
public readonly IReadOnlyDictionary<string, MusicInfo> Music;
public readonly ITerrainInfo TerrainInfo;
public readonly IReadOnlyDictionary<string, MiniYamlNode> ModelSequences;
public Ruleset(
IReadOnlyDictionary<string, ActorInfo> actors,
IReadOnlyDictionary<string, WeaponInfo> weapons,
IReadOnlyDictionary<string, SoundInfo> voices,
IReadOnlyDictionary<string, SoundInfo> notifications,
IReadOnlyDictionary<string, MusicInfo> music,
ITerrainInfo terrainInfo,
IReadOnlyDictionary<string, MiniYamlNode> modelSequences)
{
Actors = new ActorInfoDictionary(actors);
Weapons = weapons;
Voices = voices;
Notifications = notifications;
Music = music;
TerrainInfo = terrainInfo;
ModelSequences = modelSequences;
foreach (var a in Actors.Values)
{
foreach (var t in a.TraitInfos<IRulesetLoaded>())
{
try
{
t.RulesetLoaded(this, a);
}
catch (YamlException e)
{
throw new YamlException($"Actor type {a.Name}: {e.Message}");
}
}
}
foreach (var weapon in Weapons)
{
if (weapon.Value.Projectile is IRulesetLoaded<WeaponInfo> projectileLoaded)
{
try
{
projectileLoaded.RulesetLoaded(this, weapon.Value);
}
catch (YamlException e)
{
throw new YamlException($"Projectile type {weapon.Key}: {e.Message}");
}
}
foreach (var warhead in weapon.Value.Warheads)
{
if (warhead is IRulesetLoaded<WeaponInfo> cacher)
{
try
{
cacher.RulesetLoaded(this, weapon.Value);
}
catch (YamlException e)
{
throw new YamlException($"Weapon type {weapon.Key}: {e.Message}");
}
}
}
}
}
public IEnumerable<KeyValuePair<string, MusicInfo>> InstalledMusic { get { return Music.Where(m => m.Value.Exists); } }
static IReadOnlyDictionary<string, T> MergeOrDefault<T>(string name,
IReadOnlyFileSystem fileSystem,
IEnumerable<string> files,
MiniYaml additional,
IReadOnlyDictionary<string, T> defaults,
Func<MiniYamlNode, T> makeObject,
Func<MiniYamlNode, bool> filterNode = null)
{
if (additional == null && defaults != null)
return defaults;
IEnumerable<MiniYamlNode> yamlNodes = MiniYaml.Load(fileSystem, files, additional);
// Optionally, the caller can filter out elements from the loaded set of nodes. Default behavior is unfiltered.
if (filterNode != null)
yamlNodes = yamlNodes.Where(k => !filterNode(k));
return yamlNodes.ToDictionaryWithConflictLog(k => k.Key.ToLowerInvariant(), makeObject, "LoadFromManifest<" + name + ">");
}
public static Ruleset LoadDefaults(ModData modData)
{
var m = modData.Manifest;
var fs = modData.DefaultFileSystem;
Ruleset ruleset = null;
void LoadRuleset()
{
var actors = MergeOrDefault("Manifest,Rules", fs, m.Rules, null, null,
k => new ActorInfo(modData.ObjectCreator, k.Key.ToLowerInvariant(), k.Value),
filterNode: n => n.Key.StartsWith(ActorInfo.AbstractActorPrefix));
var weapons = MergeOrDefault("Manifest,Weapons", fs, m.Weapons, null, null,
k => new WeaponInfo(k.Value));
var voices = MergeOrDefault("Manifest,Voices", fs, m.Voices, null, null,
k => new SoundInfo(k.Value));
var notifications = MergeOrDefault("Manifest,Notifications", fs, m.Notifications, null, null,
k => new SoundInfo(k.Value));
var music = MergeOrDefault("Manifest,Music", fs, m.Music, null, null,
k => new MusicInfo(k.Key, k.Value));
var modelSequences = MergeOrDefault("Manifest,ModelSequences", fs, m.ModelSequences, null, null,
k => k);
// The default ruleset does not include a preferred tileset
ruleset = new Ruleset(actors, weapons, voices, notifications, music, null, modelSequences);
}
if (modData.IsOnMainThread)
{
modData.HandleLoadingProgress();
var loader = new Task(LoadRuleset);
loader.Start();
// Animate the loadscreen while we wait
while (!loader.Wait(40))
modData.HandleLoadingProgress();
}
else
LoadRuleset();
return ruleset;
}
public static Ruleset LoadDefaultsForTileSet(ModData modData, string tileSet)
{
var dr = modData.DefaultRules;
var terrainInfo = modData.DefaultTerrainInfo[tileSet];
return new Ruleset(dr.Actors, dr.Weapons, dr.Voices, dr.Notifications, dr.Music, terrainInfo, dr.ModelSequences);
}
public static Ruleset Load(ModData modData, IReadOnlyFileSystem fileSystem, string tileSet,
MiniYaml mapRules, MiniYaml mapWeapons, MiniYaml mapVoices, MiniYaml mapNotifications,
MiniYaml mapMusic, MiniYaml mapModelSequences)
{
var m = modData.Manifest;
var dr = modData.DefaultRules;
Ruleset ruleset = null;
void LoadRuleset()
{
var actors = MergeOrDefault("Rules", fileSystem, m.Rules, mapRules, dr.Actors,
k => new ActorInfo(modData.ObjectCreator, k.Key.ToLowerInvariant(), k.Value),
filterNode: n => n.Key.StartsWith(ActorInfo.AbstractActorPrefix));
var weapons = MergeOrDefault("Weapons", fileSystem, m.Weapons, mapWeapons, dr.Weapons,
k => new WeaponInfo(k.Value));
var voices = MergeOrDefault("Voices", fileSystem, m.Voices, mapVoices, dr.Voices,
k => new SoundInfo(k.Value));
var notifications = MergeOrDefault("Notifications", fileSystem, m.Notifications, mapNotifications, dr.Notifications,
k => new SoundInfo(k.Value));
var music = MergeOrDefault("Music", fileSystem, m.Music, mapMusic, dr.Music,
k => new MusicInfo(k.Key, k.Value));
// TODO: Add support for merging custom terrain modifications
var terrainInfo = modData.DefaultTerrainInfo[tileSet];
var modelSequences = dr.ModelSequences;
if (mapModelSequences != null)
modelSequences = MergeOrDefault("ModelSequences", fileSystem, m.ModelSequences, mapModelSequences, dr.ModelSequences,
k => k);
ruleset = new Ruleset(actors, weapons, voices, notifications, music, terrainInfo, modelSequences);
}
if (modData.IsOnMainThread)
{
modData.HandleLoadingProgress();
var loader = new Task(LoadRuleset);
loader.Start();
// Animate the loadscreen while we wait
while (!loader.Wait(40))
modData.HandleLoadingProgress();
}
else
LoadRuleset();
return ruleset;
}
static bool AnyCustomYaml(MiniYaml yaml)
{
return yaml != null && (yaml.Value != null || yaml.Nodes.Length > 0);
}
static bool AnyFlaggedTraits(ModData modData, IEnumerable<MiniYamlNode> actors)
{
foreach (var actorNode in actors)
{
foreach (var traitNode in actorNode.Value.Nodes)
{
try
{
var traitName = traitNode.Key.Split('@')[0];
var traitType = modData.ObjectCreator.FindType(traitName + "Info");
if (traitType != null && traitType.GetInterface(nameof(ILobbyCustomRulesIgnore)) == null)
return true;
}
catch (Exception ex)
{
Log.Write("debug", "Error in AnyFlaggedTraits\n" + ex.ToString());
}
}
}
return false;
}
public static bool DefinesUnsafeCustomRules(ModData modData, IReadOnlyFileSystem fileSystem,
MiniYaml mapRules, MiniYaml mapWeapons, MiniYaml mapVoices, MiniYaml mapNotifications, MiniYaml mapSequences)
{
// Maps that define any weapon, voice, notification, or sequence overrides are always flagged
if (AnyCustomYaml(mapWeapons) || AnyCustomYaml(mapVoices) || AnyCustomYaml(mapNotifications) || AnyCustomYaml(mapSequences))
return true;
// Any trait overrides that aren't explicitly whitelisted are flagged
if (mapRules == null)
return false;
if (AnyFlaggedTraits(modData, mapRules.Nodes))
return true;
if (mapRules.Value != null)
{
var mapFiles = FieldLoader.GetValue<ImmutableArray<string>>("value", mapRules.Value);
foreach (var f in mapFiles)
if (AnyFlaggedTraits(modData, MiniYaml.FromStream(fileSystem.Open(f), f)))
return true;
}
return false;
}
}
}

View File

@@ -0,0 +1,97 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* This file is part of OpenRA, which is free software. It is made
* available to you under the terms of the GNU General Public License
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version. For more
* information, see COPYING.
*/
#endregion
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Collections.Immutable;
namespace OpenRA.GameRules
{
public class SoundInfo
{
public readonly FrozenDictionary<string, ImmutableArray<string>> Variants = FrozenDictionary<string, ImmutableArray<string>>.Empty;
public readonly FrozenDictionary<string, ImmutableArray<string>> Prefixes = FrozenDictionary<string, ImmutableArray<string>>.Empty;
public readonly FrozenDictionary<string, ImmutableArray<string>> Voices = FrozenDictionary<string, ImmutableArray<string>>.Empty;
public readonly FrozenDictionary<string, ImmutableArray<string>> Notifications = FrozenDictionary<string, ImmutableArray<string>>.Empty;
public readonly string DefaultVariant = ".aud";
public readonly string DefaultPrefix = "";
public readonly FrozenSet<string> DisableVariants = FrozenSet<string>.Empty;
public readonly FrozenSet<string> DisablePrefixes = FrozenSet<string>.Empty;
public readonly Lazy<FrozenDictionary<string, SoundPool>> VoicePools;
public readonly Lazy<FrozenDictionary<string, SoundPool>> NotificationsPools;
public SoundInfo(MiniYaml y)
{
FieldLoader.Load(this, y);
VoicePools = Exts.Lazy(() => Voices.ToFrozenDictionary(a => a.Key, a => new SoundPool(1f, SoundPool.DefaultInterruptType, a.Value)));
NotificationsPools = Exts.Lazy(() => ParseSoundPool(y, "Notifications"));
}
static FrozenDictionary<string, SoundPool> ParseSoundPool(MiniYaml y, string key)
{
var classifiction = y.NodeWithKey(key);
var ret = new Dictionary<string, SoundPool>(classifiction.Value.Nodes.Length);
foreach (var t in classifiction.Value.Nodes)
{
var volumeModifier = 1f;
var volumeModifierNode = t.Value.NodeWithKeyOrDefault(nameof(SoundPool.VolumeModifier));
if (volumeModifierNode != null)
volumeModifier = FieldLoader.GetValue<float>(volumeModifierNode.Key, volumeModifierNode.Value.Value);
var interruptType = SoundPool.DefaultInterruptType;
var interruptTypeNode = t.Value.NodeWithKeyOrDefault(nameof(SoundPool.InterruptType));
if (interruptTypeNode != null)
interruptType = FieldLoader.GetValue<SoundPool.InterruptType>(interruptTypeNode.Key, interruptTypeNode.Value.Value);
var names = FieldLoader.GetValue<ImmutableArray<string>>(t.Key, t.Value.Value);
var sp = new SoundPool(volumeModifier, interruptType, names);
ret.Add(t.Key, sp);
}
return ret.ToFrozenDictionary();
}
}
public class SoundPool
{
public enum InterruptType { DoNotPlay, Interrupt, Overlap }
public const InterruptType DefaultInterruptType = InterruptType.DoNotPlay;
public readonly float VolumeModifier;
public readonly InterruptType Type;
readonly ImmutableArray<string> clips;
readonly List<string> liveclips = [];
public SoundPool(float volumeModifier, InterruptType interruptType, ImmutableArray<string> clips)
{
VolumeModifier = volumeModifier;
Type = interruptType;
this.clips = clips;
}
public string GetNext()
{
if (liveclips.Count == 0)
liveclips.AddRange(clips);
// Avoid crashing if there's no clips at all
if (liveclips.Count == 0)
return null;
var i = Game.CosmeticRandom.Next(liveclips.Count);
var s = liveclips[i];
liveclips.RemoveAt(i);
return s;
}
}
}

View File

@@ -0,0 +1,268 @@
#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* This file is part of OpenRA, which is free software. It is made
* available to you under the terms of the GNU General Public License
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version. For more
* information, see COPYING.
*/
#endregion
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using OpenRA.Effects;
using OpenRA.Primitives;
using OpenRA.Traits;
namespace OpenRA.GameRules
{
public class ProjectileArgs
{
public WeaponInfo Weapon;
public int[] DamageModifiers;
public int[] InaccuracyModifiers;
public int[] RangeModifiers;
public WAngle Facing;
public Func<WAngle> CurrentMuzzleFacing;
public WPos Source;
public Func<WPos> CurrentSource;
public Actor SourceActor;
public WPos PassiveTarget;
public Target GuidedTarget;
}
public class WarheadArgs
{
public WeaponInfo Weapon;
public int[] DamageModifiers = [];
public WPos? Source;
public WRot ImpactOrientation;
public WPos ImpactPosition;
public Actor SourceActor;
public Target WeaponTarget;
public WarheadArgs(ProjectileArgs args)
{
Weapon = args.Weapon;
DamageModifiers = args.DamageModifiers;
ImpactPosition = args.PassiveTarget;
Source = args.Source;
SourceActor = args.SourceActor;
WeaponTarget = args.GuidedTarget;
}
// For places that only want to update some of the fields (usually DamageModifiers)
public WarheadArgs(WarheadArgs args)
{
Weapon = args.Weapon;
DamageModifiers = args.DamageModifiers;
Source = args.Source;
SourceActor = args.SourceActor;
WeaponTarget = args.WeaponTarget;
}
// Default empty constructor for callers that want to initialize fields themselves
public WarheadArgs() { }
}
public interface IProjectile : IEffect { }
public interface IProjectileInfo { IProjectile Create(ProjectileArgs args); }
public sealed class WeaponInfo
{
[Desc("The maximum range the weapon can fire.")]
public readonly WDist Range = WDist.Zero;
[Desc("First burst is aimed at this offset relative to target position.")]
public readonly WVec FirstBurstTargetOffset = WVec.Zero;
[Desc("Each burst after the first lands by this offset away from the previous burst.")]
public readonly WVec FollowingBurstTargetOffset = WVec.Zero;
[Desc("The sound played each time the weapon is fired.")]
public readonly ImmutableArray<string> Report = default;
[Desc("Sound played only on first burst in a salvo.")]
public readonly ImmutableArray<string> StartBurstReport = default;
[Desc("The sound played when the weapon is reloaded.")]
public readonly ImmutableArray<string> AfterFireSound = default;
[Desc("Delay in ticks to play reloading sound.")]
public readonly int AfterFireSoundDelay = 0;
[Desc("Delay in ticks between reloading ammo magazines.")]
public readonly int ReloadDelay = 1;
[Desc("Number of shots in a single ammo magazine.")]
public readonly int Burst = 1;
[Desc("Can this weapon target the attacker itself?")]
public readonly bool CanTargetSelf = false;
[Desc("What types of targets are affected.")]
public readonly BitSet<TargetableType> ValidTargets = new("Ground", "Water");
[Desc("What types of targets are unaffected.", "Overrules ValidTargets.")]
public readonly BitSet<TargetableType> InvalidTargets;
static readonly BitSet<TargetableType> TargetTypeAir = new("Air");
[Desc("If weapon is not directly targeting an actor and targeted position is above this altitude,",
"the weapon will ignore terrain target types and only check TargetTypeAir for validity.")]
public readonly WDist AirThreshold = new(128);
[Desc("Delay in ticks between firing shots from the same ammo magazine. If one entry, it will be used for all bursts.",
"If multiple entries, their number needs to match Burst - 1.")]
public readonly ImmutableArray<int> BurstDelays = [5];
[Desc("The minimum range the weapon can fire.")]
public readonly WDist MinRange = WDist.Zero;
[Desc("Does this weapon aim at the target's center regardless of other targetable offsets?")]
public readonly bool TargetActorCenter = false;
[FieldLoader.LoadUsing(nameof(LoadProjectile))]
public readonly IProjectileInfo Projectile;
[FieldLoader.LoadUsing(nameof(LoadWarheads))]
public readonly ImmutableArray<IWarhead> Warheads = [];
/// <summary>
/// This constructor is used solely for documentation generation.
/// </summary>
public WeaponInfo() { }
public WeaponInfo(MiniYaml content)
{
// Resolve any weapon-level yaml inheritance or removals
// HACK: The "Defaults" sequence syntax prevents us from doing this generally during yaml parsing
content = content.WithNodes(MiniYaml.Merge([content.Nodes]));
FieldLoader.Load(this, content);
}
static object LoadProjectile(MiniYaml yaml)
{
var proj = yaml.NodeWithKeyOrDefault("Projectile")?.Value;
if (proj == null)
return null;
var ret = Game.CreateObject<IProjectileInfo>(proj.Value + "Info");
if (ret == null)
return null;
FieldLoader.Load(ret, proj);
return ret;
}
static object LoadWarheads(MiniYaml yaml)
{
var retList = new List<IWarhead>();
foreach (var node in yaml.Nodes.Where(n => n.Key.StartsWith("Warhead", StringComparison.Ordinal)))
{
var ret = Game.CreateObject<IWarhead>(node.Value.Value + "Warhead");
if (ret == null)
continue;
FieldLoader.Load(ret, node.Value);
retList.Add(ret);
}
return retList.ToImmutableArray();
}
public bool IsValidTarget(BitSet<TargetableType> targetTypes)
{
return ValidTargets.Overlaps(targetTypes) && !InvalidTargets.Overlaps(targetTypes);
}
/// <summary>Checks if the weapon is valid against (can target) the target.</summary>
public bool IsValidAgainst(in Target target, World world, Actor firedBy)
{
if (target.Type == TargetType.Actor)
return IsValidAgainst(target.Actor, firedBy);
if (target.Type == TargetType.FrozenActor)
return IsValidAgainst(target.FrozenActor, firedBy);
if (target.Type == TargetType.Terrain)
{
var dat = world.Map.DistanceAboveTerrain(target.CenterPosition);
if (dat > AirThreshold)
return IsValidTarget(TargetTypeAir);
var cell = world.Map.CellContaining(target.CenterPosition);
if (!world.Map.Contains(cell))
return false;
var cellInfo = world.Map.GetTerrainInfo(cell);
if (!IsValidTarget(cellInfo.TargetTypes))
return false;
return true;
}
return false;
}
/// <summary>Checks if the weapon is valid against (can target) the actor.</summary>
public bool IsValidAgainst(Actor victim, Actor firedBy)
{
if (!CanTargetSelf && victim == firedBy)
return false;
var targetTypes = victim.GetEnabledTargetTypes();
return IsValidTarget(targetTypes);
}
/// <summary>Checks if the weapon is valid against (can target) the frozen actor.</summary>
public bool IsValidAgainst(FrozenActor victim, Actor firedBy)
{
if (!victim.IsValid)
return false;
if (!CanTargetSelf && victim.Actor == firedBy)
return false;
return IsValidTarget(victim.TargetTypes);
}
/// <summary>Applies all the weapon's warheads to the target.</summary>
public void Impact(in Target target, WarheadArgs args)
{
var world = args.SourceActor.World;
foreach (var warhead in Warheads)
{
if (warhead.Delay > 0)
{
// Lambdas can't use 'in' variables, so capture a copy for later
var delayedTarget = target;
world.AddFrameEndTask(w => w.Add(new DelayedImpact(warhead.Delay, warhead, delayedTarget, args)));
}
else
warhead.DoImpact(target, args);
}
}
/// <summary>Applies all the weapon's warheads to the target. Only use for projectile-less, special-case impacts.</summary>
public void Impact(in Target target, Actor firedBy)
{
// The impact will happen immediately at target.CenterPosition.
var args = new WarheadArgs
{
Weapon = this,
SourceActor = firedBy,
WeaponTarget = target
};
if (firedBy.OccupiesSpace != null)
args.Source = firedBy.CenterPosition;
Impact(target, args);
}
}
}