Aetheria/Assets/Scripts/ServerShared/Entity.cs

2518 lines
92 KiB
C#
Raw Normal View History

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.Linq;
2026-06-21 09:54:15 +01:00
using GameCult.Aetheria.State.Verse;
using UniRx;
2026-06-21 09:37:54 +01:00
using bool2 = CultMath.bool2;
using cfloat2 = CultMath.float2;
using cfloat3 = CultMath.float3;
using float2 = Unity.Mathematics.float2;
using float3 = Unity.Mathematics.float3;
using int2 = Unity.Mathematics.int2;
2026-06-21 09:37:54 +01:00
internal static class AetheriaScalar
{
public static float Clamp(float value, float min, float max)
{
return value < min ? min : value > max ? max : value;
}
public static float Lerp(float from, float to, float t)
{
return from + (to - from) * t;
}
public static float Unlerp(float from, float to, float value)
{
return Math.Abs(to - from) <= 0.000001f ? 0 : (value - from) / (to - from);
}
public static float Saturate(float value)
{
return Clamp(value, 0, 1);
}
}
public abstract class Entity
{
public Zone Zone;
public Faction Faction;
public EquippableItem Hull;
public EquippedItem EquippedHull;
public int DaemonEntityIndex = -1;
public float3 Position;
2026-06-21 09:37:54 +01:00
public float2 Direction = new float2(0,1);
public float2 Velocity;
2026-06-21 09:37:54 +01:00
public cfloat3 CultPosition
{
get => AetheriaMath.ToCult(Position);
set => Position = AetheriaMath.ToUnity(value);
}
public cfloat2 CultPositionXZ
{
get => AetheriaMath.ToCultXZ(Position);
set => Position.xz = AetheriaMath.ToUnity(value);
}
public float CultPositionY
{
get => Position.y;
set => Position.y = value;
}
public cfloat2 CultDirection
{
get => AetheriaMath.ToCult(Direction);
set => Direction = AetheriaMath.ToUnity(value);
}
public cfloat2 CultVelocity
{
get => AetheriaMath.ToCult(Velocity);
set => Velocity = AetheriaMath.ToUnity(value);
}
public float[,] Temperature;
public float[,] NewTemperature;
public bool2[,] HullConductivity;
public float[,] ThermalMass;
public readonly ReactiveCollection<EquippedItem> Equipment = new ReactiveCollection<EquippedItem>();
public readonly ReactiveCollection<EquippedCargoBay> CargoBays = new ReactiveCollection<EquippedCargoBay>();
public readonly ReactiveCollection<EquippedDockingBay> DockingBays = new ReactiveCollection<EquippedDockingBay>();
public readonly ReactiveCollection<Entity> VisibleEntities = new ReactiveCollection<Entity>();
public readonly ReactiveDictionary<Entity, bool> EntityHostility = new ReactiveDictionary<Entity, bool>();
public readonly ReactiveCollection<Entity> VisibleEnemies = new ReactiveCollection<Entity>();
public readonly ReactiveCollection<Entity> VisibleFriendlies = new ReactiveCollection<Entity>();
public Entity Parent;
public List<Entity> Children = new List<Entity>();
public ReactiveProperty<Entity> Target = new ReactiveProperty<Entity>((Entity)null);
2021-01-05 02:56:17 +00:00
public float3 LookDirection;
2026-06-21 09:37:54 +01:00
public cfloat3 CultLookDirection
{
get => AetheriaMath.ToCult(LookDirection);
set => LookDirection = AetheriaMath.ToUnity(value);
}
public cfloat2 CultLookDirectionXZ
{
get => AetheriaMath.ToCultXZ(LookDirection);
set => LookDirection = AetheriaMath.ToUnityXZ(value);
}
public string Name;
2021-05-16 05:24:51 +01:00
// public int Population;
public readonly Dictionary<string, float> Messages = new Dictionary<string, float>();
public readonly Dictionary<object, float> VisibilitySources = new Dictionary<object, float>();
2026-06-21 09:37:54 +01:00
public readonly ReactiveDictionary<Entity, float> EntityInfoGathered = new ReactiveDictionary<Entity, float>();
public readonly Dictionary<HardpointData, (cfloat3 position, cfloat3 direction)> HardpointTransforms =
new Dictionary<HardpointData, (cfloat3 position, cfloat3 direction)>();
public (List<Weapon> weapons, List<EquippedItem> items)[] WeaponGroups;
public List<IPopulationAssignment> PopulationAssignments = new List<IPopulationAssignment>();
public EquippedItem[,] GearOccupancy;
public HardpointData[,] Hardpoints;
2021-01-08 12:44:10 +00:00
public float[,] Armor;
public float[,] MaxArmor;
private EquippedItem[] _orderedEquipment;
private List<Weapon> _weapons = new List<Weapon>();
private List<Capacitor> _capacitors = new List<Capacitor>();
private List<Reactor> _reactors = new List<Reactor>();
private List<Radiator> _heatsinks = new List<Radiator>();
private List<ConsumableItemEffect> _activeConsumables = new List<ConsumableItemEffect>();
public IReadOnlyList<ConsumableItemEffect> ActiveConsumables => _activeConsumables;
2021-01-05 02:56:17 +00:00
protected bool _active;
private bool _heatsinksEnabled = true;
public bool HeatsinksEnabled
{
get => _heatsinksEnabled;
set
{
if (value == _heatsinksEnabled) return;
_heatsinksEnabled = value;
foreach (var heatsink in _heatsinks)
heatsink.Item.Enabled.Value = value;
}
}
public EntitySettings Settings { get; }
public bool OverrideShutdown { get; set; }
public float TractorPower { get; set; }
public bool Active
{
get => _active;
}
2026-06-21 09:37:54 +01:00
public void RestoreActiveState(bool active)
{
_active = active;
}
public IEnumerable<Weapon> Weapons
{
get => _weapons;
}
public Shield Shield { get; private set; }
public Cockpit Cockpit { get; private set; }
public Sensor Sensor { get; private set; }
public float Heatstroke { get; private set; }
public float Hypothermia { get; private set; }
public float TargetRange { get; private set; }
public float MaxTemp { get; private set; }
public float MinTemp { get; private set; }
public ItemManager ItemManager { get; }
public int AssignedPopulation => PopulationAssignments.Sum(pa => pa.AssignedPopulation);
public float Mass { get; private set; }
public float Visibility => VisibilitySources.Values.Sum();
public Subject<Entity> IncomingHit = new Subject<Entity>();
2021-01-08 12:44:10 +00:00
public Subject<(int2 pos, float damage)> ArmorDamage = new Subject<(int2, float)>();
public Subject<(EquippedItem item, float damage)> ItemDamage = new Subject<(EquippedItem, float)>();
// public Subject<EquippedItem> ItemOffline = new Subject<EquippedItem>();
// public Subject<EquippedItem> ItemOnline = new Subject<EquippedItem>();
2021-01-08 12:44:10 +00:00
public Subject<float> HullDamage = new Subject<float>();
public Subject<Entity> Docked = new Subject<Entity>();
public Subject<Unit> HeatstrokeRisk = new Subject<Unit>();
public Subject<Unit> HeatstrokeDeath = new Subject<Unit>();
public Subject<Unit> HypothermiaRisk = new Subject<Unit>();
public Subject<Unit> HypothermiaDeath = new Subject<Unit>();
2021-04-30 11:19:25 +01:00
public Subject<Entity> TargetedBy = new Subject<Entity>();
public ReactiveProperty<int> TargetedByCount = new ReactiveProperty<int>(0);
public ReactiveProperty<SecurityLevel> CurrentSecurityLevel = new ReactiveProperty<SecurityLevel>(SecurityLevel.Open);
public ReadOnlyReactiveProperty<bool> PresencePermitted;
public UniRx.IObservable<EquippedItem> ItemDestroyed;
public UniRx.IObservable<int2> HullArmorDepleted;
public UniRx.IObservable<HardpointData> HardpointArmorDepleted;
public UniRx.IObservable<Weapon> WeaponDestroyed;
public UniRx.IObservable<CauseOfDeath> Death;
2021-01-08 12:44:10 +00:00
private List<IDisposable> _subscriptions = new List<IDisposable>();
private Dictionary<Entity, List<IDisposable>> _watchedEntitySubscriptions = new Dictionary<Entity, List<IDisposable>>();
public virtual void Activate()
{
//ItemManager.Log($"Entity {Name} is activating!");
_active = true;
Heatstroke = 0;
// The entity's presence is permitted when its faction owns the zone, or the current security level at its location is low enough
PresencePermitted = new ReadOnlyReactiveProperty<bool>(CurrentSecurityLevel.Select(security =>
{
var factionRelationship = GetFactionRelationship(Zone.GalaxyZone.Owner);
var presencePermitted = IsPresencePermitted(factionRelationship, security);
return presencePermitted;
}), initialValue: true);
foreach (var item in Equipment)
foreach (var behavior in item.Behaviors)
{
if(behavior is IInitializableBehavior initializableBehavior)
initializableBehavior.Initialize();
}
foreach(var entity in Zone.Entities)
{
EntityInfoGathered[entity] = 0;
EntityHostility[entity] = IsHostileTo(entity);
}
_subscriptions.Add(Zone.Entities.ObserveAdd().Subscribe(add =>
{
EntityInfoGathered[add.Value] = 0;
EntityHostility[add.Value] = IsHostileTo(add.Value);
}));
_subscriptions.Add(Zone.Entities.ObserveRemove().Subscribe(remove =>
{
if (Target.Value == remove.Value) Target.Value = null;
EntityInfoGathered.Remove(remove.Value);
EntityHostility.Remove(remove.Value);
VisibleEntities.Remove(remove.Value);
VisibleEnemies.Remove(remove.Value);
VisibleFriendlies.Remove(remove.Value);
}));
_subscriptions.Add(VisibleEnemies.ObserveRemove().Subscribe(remove =>
{
if (Target.Value == remove.Value) Target.Value = null;
}));
_subscriptions.Add(Target.Subscribe(entity => entity?.TargetedBy.OnNext(this)));
_subscriptions.Add(TargetedBy.Subscribe(enemy =>
{
TargetedByCount.Value++;
enemy.Target.Where(t => t != this).Take(1).Subscribe(_ => TargetedByCount.Value--);
}));
//
_subscriptions.Add(EntityInfoGathered.ObserveReplace().Subscribe(replace =>
{
if (replace.OldValue < ItemManager.GameplaySettings.TargetDetectionInfoThreshold &&
replace.NewValue > ItemManager.GameplaySettings.TargetDetectionInfoThreshold)
{
VisibleEntities.Add(replace.Key);
if(EntityHostility[replace.Key])
VisibleEnemies.Add(replace.Key);
else VisibleFriendlies.Add(replace.Key);
}
if (replace.OldValue > ItemManager.GameplaySettings.TargetDetectionInfoThreshold &&
replace.NewValue < ItemManager.GameplaySettings.TargetDetectionInfoThreshold)
{
VisibleEntities.Remove(replace.Key);
VisibleEnemies.Remove(replace.Key);
VisibleFriendlies.Remove(replace.Key);
}
}));
// Create subscriptions to events occurring on any visible entity
_subscriptions.Add(VisibleEntities.ObserveAdd()
.Select(add=>add.Value)
.Subscribe(entity =>
{
var newVisibleEntitySubscriptions = new List<IDisposable>
{
// Respond to changes in the target's hostility status by updating the contents of VisibleEnemies and VisibleFriendlies
EntityHostility.ObserveReplace()
.Where(replace => replace.Key == entity)
.Select(replace => replace.NewValue)
.Subscribe(isHostile =>
{
(isHostile ? VisibleEnemies : VisibleFriendlies).Add(entity);
(isHostile ? VisibleFriendlies : VisibleEnemies).Remove(entity);
})
};
_watchedEntitySubscriptions[entity] = newVisibleEntitySubscriptions;
}));
// Cleanup visible entity subscriptions
_subscriptions.Add(VisibleEntities.ObserveRemove().Subscribe(disappearingEntity =>
{
foreach(var subscription in _watchedEntitySubscriptions[disappearingEntity.Value]) subscription.Dispose();
_watchedEntitySubscriptions.Remove(disappearingEntity.Value);
}));
if(WeaponGroups.All(wg=>!wg.items.Any()))
GenerateWeaponGroups();
}
public virtual void Deactivate()
{
//ItemManager.Log($"Entity {Name} is deactivating!");
foreach(var s in _subscriptions) s.Dispose();
_subscriptions.Clear();
foreach(var ss in _watchedEntitySubscriptions.Values) foreach(var s in ss) s.Dispose();
_watchedEntitySubscriptions.Clear();
_active = false;
EntityInfoGathered.Clear();
VisibleEntities.Clear();
VisibleEnemies.Clear();
VisibleFriendlies.Clear();
}
2021-01-05 02:56:17 +00:00
public Entity(ItemManager itemManager, Zone zone, EquippableItem hull, EntitySettings settings)
{
Settings = settings;
ItemManager = itemManager;
Zone = zone;
Hull = hull;
2026-06-14 06:19:20 +01:00
var typedHull = itemManager.GetRuntimeItem(hull);
if (typedHull == null)
throw new InvalidOperationException($"Unable to construct entity: missing typed hull row for {hull?.ItemKey}");
2026-06-14 06:19:20 +01:00
Name = typedHull.Name;
2021-01-08 12:44:10 +00:00
MapEntity();
WeaponGroups = new (List<Weapon> weapons, List<EquippedItem> items)[itemManager.GameplaySettings.WeaponGroupCount];
for(int i=0; i<itemManager.GameplaySettings.WeaponGroupCount; i++)
WeaponGroups[i] = (new List<Weapon>(), new List<EquippedItem>());
ItemDestroyed = ItemDamage.Where(x => x.item.EquippableItem.Durability < .01f).Select(x=>x.item);
WeaponDestroyed = ItemDestroyed.Select(x => x.Behaviors.FirstOrDefault(b => b is Weapon) as Weapon).Where(x => x != null);
HullArmorDepleted = ArmorDamage.Where(x => Armor[x.pos.x, x.pos.y] < .01f).Select(x => x.pos);
Death = HullDamage.Where(_ => Hull.Durability < .01f).Select(_ => CauseOfDeath.HullDestroyed)
.Merge(HeatstrokeDeath.Select(_ => CauseOfDeath.Heatstroke))
.Merge(HypothermiaDeath.Select(_ => CauseOfDeath.Hypothermia))
.Merge(ItemDestroyed.Where(i=>i.GetBehavior<Cockpit>()!=null).Select(_ => CauseOfDeath.CockpitDestroyed));
//CurrentSecurityLevel.Value = SecurityLevel.Open;
}
2021-04-30 11:19:25 +01:00
public bool IsHostileTo(Entity other, bool recursive = false)
{
if (Faction == null)
return !recursive && other.Faction != null && other.IsHostileTo(this, true);
// TODO: Inter-faction hostility
// When the entity faction owns the zone, they are hostile to trespassers or those hostile to them
2026-06-16 11:43:37 +01:00
if (Faction.HasSameKey(Zone.GalaxyZone.Owner))
return recursive ? !(other.PresencePermitted?.Value ?? true) : !(other.PresencePermitted?.Value ?? true)|| other.IsHostileTo(this, true);
return !recursive && other.IsHostileTo(this, true);
}
// TODO: Inter-faction relationships
public FactionRelationship GetFactionRelationship(Faction faction)
{
if (faction == null)
return FactionRelationship.Neutral;
if (this is Ship {IsPlayerShip: true})
return Zone.Galaxy.FactionRelationships[faction];
2026-06-16 11:43:37 +01:00
return faction.HasSameKey(Faction) ? FactionRelationship.Beloved : FactionRelationship.Neutral;
}
public static bool IsPresencePermitted(FactionRelationship relationship, SecurityLevel securityLevel) =>
(int) relationship - (int) securityLevel > 0;
public static bool IsDockingPermitted(FactionRelationship relationship, SecurityLevel securityLevel) =>
(int) relationship - (int) securityLevel > 1;
public void ActivateConsumable(ConsumableItem item)
{
_activeConsumables.Add(new ConsumableItemEffect(item, this));
}
public void RestoreThermalExposure(float heatstroke, float hypothermia)
{
2026-06-21 09:37:54 +01:00
Heatstroke = AetheriaScalar.Saturate(heatstroke);
Hypothermia = AetheriaScalar.Saturate(hypothermia);
}
public void RestoreActiveConsumable(ConsumableItem item, float remainingDuration, float duration)
{
if (item == null)
return;
_activeConsumables.Add(new ConsumableItemEffect(item, this, remainingDuration, duration));
}
public void RestoreStatGrids(IReadOnlyList<AetheriaRuntimeEntityStatGridSnapshot> grids)
{
if (grids == null)
return;
foreach (var grid in grids)
{
switch (grid.Name)
{
case "temperature":
RestoreFloatGrid(grid, Temperature);
RestoreFloatGrid(grid, NewTemperature);
break;
case "thermal_mass":
RestoreFloatGrid(grid, ThermalMass);
break;
case "armor":
RestoreFloatGrid(grid, Armor);
break;
case "max_armor":
RestoreFloatGrid(grid, MaxArmor);
break;
case "hull_conductivity_x":
RestoreHullConductivityGrid(grid, axis: 0);
break;
case "hull_conductivity_y":
RestoreHullConductivityGrid(grid, axis: 1);
break;
}
}
}
2026-06-14 23:09:49 +01:00
public ConsumableItemEffect FindActiveConsumable(string itemKey)
{
return _activeConsumables.FirstOrDefault(ac => ac.Item?.ItemKey == itemKey);
}
private static void RestoreFloatGrid(AetheriaRuntimeEntityStatGridSnapshot grid, float[,] target)
{
if (!CanRestoreGrid(grid, target))
return;
var index = 0;
for (var y = 0; y < grid.Height; y++)
for (var x = 0; x < grid.Width; x++)
target[x, y] = (float)grid.Values[index++];
}
private void RestoreHullConductivityGrid(AetheriaRuntimeEntityStatGridSnapshot grid, int axis)
{
if (!CanRestoreGrid(grid, HullConductivity))
return;
var index = 0;
for (var y = 0; y < grid.Height; y++)
for (var x = 0; x < grid.Width; x++)
{
var conductivity = HullConductivity[x, y];
if (axis == 0)
conductivity.x = grid.Values[index++] >= 0.5;
else
conductivity.y = grid.Values[index++] >= 0.5;
HullConductivity[x, y] = conductivity;
}
}
private static bool CanRestoreGrid<T>(AetheriaRuntimeEntityStatGridSnapshot grid, T[,] target)
{
return grid != null &&
target != null &&
grid.Width == target.GetLength(0) &&
grid.Height == target.GetLength(1) &&
grid.Values?.Count == grid.Width * grid.Height;
}
public bool CanActivateConsumable(AetheriaRuntimeCatalogItem item)
{
2026-06-14 23:09:49 +01:00
var itemKey = GetItemKey(item);
return item != null && !string.IsNullOrWhiteSpace(itemKey) && (item.Stackable || FindActiveConsumable(itemKey) == null);
}
public bool TryActivateConsumable(AetheriaRuntimeCatalogItem typedItem)
{
if (!CanActivateConsumable(typedItem)) return false;
2026-06-14 23:09:49 +01:00
var itemKey = GetItemKey(typedItem);
var bay = FindItemInCargo(itemKey);
if (bay == null) return false;
2026-06-14 23:09:49 +01:00
var item = (ConsumableItem)bay.GetFirstItem(itemKey);
ActivateConsumable(item);
bay.Remove(item);
return true;
}
2026-06-14 23:09:49 +01:00
private static string GetItemKey(AetheriaRuntimeCatalogItem item)
{
2026-06-15 01:31:16 +01:00
return item?.ItemKey ?? "";
}
2021-01-08 12:44:10 +00:00
private void MapEntity()
{
2026-06-14 05:08:39 +01:00
var typedHull = ItemManager.GetRuntimeItem(Hull);
if (typedHull == null)
throw new InvalidOperationException($"Unable to map entity {Name}: missing typed hull row for {Hull?.ItemKey}");
2026-06-14 05:08:39 +01:00
var hullShape = GetHullShape(typedHull);
2026-06-21 09:37:54 +01:00
EquippedHull = new EquippedItem(ItemManager, Hull, new int2(0, 0), this);
Equipment.Add(EquippedHull);
2026-06-14 05:08:39 +01:00
Mass = ItemManager.GetMass(Hull);
Temperature = new float[hullShape.Width, hullShape.Height];
NewTemperature = new float[hullShape.Width, hullShape.Height];
HullConductivity = new bool2[hullShape.Width, hullShape.Height];
ThermalMass = new float[hullShape.Width, hullShape.Height];
Armor = new float[hullShape.Width, hullShape.Height];
MaxArmor = new float[hullShape.Width, hullShape.Height];
Hardpoints = new HardpointData[hullShape.Width, hullShape.Height];
foreach (var typedHardpoint in typedHull.Hardpoints)
{
2026-06-14 05:08:39 +01:00
var hardpoint = ProjectHardpoint(typedHardpoint);
foreach (var hardpointCoord in hardpoint.Shape.Coordinates)
{
var hullCoord = hardpoint.Position + hardpointCoord;
Hardpoints[hullCoord.x, hullCoord.y] = hardpoint;
}
}
2026-06-14 05:08:39 +01:00
var cellCount = Math.Max(hullShape.Coordinates.Length, 1);
foreach (var v in hullShape.Coordinates)
{
2026-06-14 05:08:39 +01:00
Armor[v.x, v.y] = (float)typedHull.HullArmor;
MaxArmor[v.x, v.y] = (float)typedHull.HullArmor;
if (Hardpoints[v.x, v.y] != null)
{
Armor[v.x, v.y] += Hardpoints[v.x, v.y].Armor;
MaxArmor[v.x, v.y] += Hardpoints[v.x, v.y].Armor;
}
Temperature[v.x, v.y] = 280;
2026-06-14 05:08:39 +01:00
ThermalMass[v.x, v.y] = (float)(typedHull.Mass * typedHull.SpecificHeat / cellCount);
}
2026-06-14 05:08:39 +01:00
GearOccupancy = new EquippedItem[hullShape.Width, hullShape.Height];
}
public void GenerateWeaponGroups()
{
foreach (var group in Weapons
.GroupBy(w => w.Item.EquippableItem.ItemKey)
.OrderBy(wg=>wg.Average(w=>w.Range))
.Select((weapons, index) => (weapons, index)))
{
WeaponGroups[group.index].weapons = group.weapons.ToList();
WeaponGroups[group.index].items = group.weapons.Select(w=>w.Item).ToList();
}
}
public void AddHeat(int2 position, float heat, bool ignoreThermalMass = false)
{
if (ignoreThermalMass)
Temperature[position.x, position.y] += heat;
else
Temperature[position.x, position.y] += heat / ThermalMass[position.x, position.y];
}
2026-06-14 23:09:49 +01:00
public int CountItemsInCargo(string itemKey)
{
int sum = 0;
foreach (var x in CargoBays)
{
2026-06-14 23:09:49 +01:00
if (x.ItemsOfType.ContainsKey(itemKey))
{
2026-06-14 23:09:49 +01:00
foreach (var i in x.ItemsOfType[itemKey]) sum += i is SimpleCommodity simpleCommodity ? simpleCommodity.Quantity : 1;
}
}
return sum;
}
2026-06-14 23:09:49 +01:00
public EquippedCargoBay FindItemInCargo(string itemKey)
{
return CargoBays.FirstOrDefault(c => c.ContainsItem(itemKey));
}
public Shape UnoccupiedSpace
{
get
{
2026-06-14 05:08:39 +01:00
var typedHull = ItemManager.GetRuntimeItem(Hull);
var hullShape = GetHullShape(typedHull);
var hullInterior = GetHullInteriorShape(typedHull);
var emptyShape = new Shape(hullShape.Width, hullShape.Height);
foreach (var v in hullShape.Coordinates)
{
2026-06-14 05:08:39 +01:00
if (hullInterior[v] && GearOccupancy[v.x, v.y] == null && Hardpoints[v.x, v.y] == null)
emptyShape[v] = true;
}
return emptyShape;
}
}
// Attempts to move a given number of items of the given type to the target Entity
// Returns the number of items successfully transferred
2026-06-15 03:12:24 +01:00
public int TryTransferItems(Entity target, string itemKey, int quantity)
{
2026-06-15 03:12:24 +01:00
if (target == null || string.IsNullOrWhiteSpace(itemKey) || quantity <= 0)
return 0;
int quantityTransferred = 0;
while (quantityTransferred < quantity)
{
2026-06-14 23:09:49 +01:00
EquippedCargoBay originInventory = CargoBays.FirstOrDefault(c => c.ContainsItem(itemKey));
if (originInventory == null) break;
2026-06-14 23:09:49 +01:00
var itemInstance = originInventory.GetFirstItem(itemKey);
if (itemInstance is SimpleCommodity simpleCommodity)
{
2026-06-21 09:37:54 +01:00
var targetQuantity = Math.Min(simpleCommodity.Quantity, quantity - quantityTransferred);
if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, simpleCommodity, targetQuantity)))
{
quantityTransferred += targetQuantity - simpleCommodity.Quantity;
break;
}
quantityTransferred += targetQuantity;
}
else if (itemInstance is CraftedItemInstance craftedItemInstance)
{
if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, craftedItemInstance)))
break;
quantityTransferred++;
}
}
return quantityTransferred;
}
public EquippableItem TryUnequip(EquippedItem item)
{
// Don't allow unequipping when the entity is active
if (_active) return null;
if (item.EquippableItem == null)
{
ItemManager.Log("Attempted to remove equipped item with no equippable item on it! This should be impossible!");
return null;
}
if (item is EquippedCargoBay cargoBay)
{
if(cargoBay.Cargo.Count > 0)
{
ItemManager.Log("Attempted to remove cargo bay that is not empty! Please check first before doing this!");
return null;
}
CargoBays.Remove(cargoBay);
}
Equipment.Remove(item);
_orderedEquipment = Equipment.OrderBy(x => x.SortPosition).ToArray();
var itemShape = GetItemShape(item.EquippableItem);
var itemCellCount = Math.Max(itemShape.Coordinates.Length, 1);
foreach (var i in GetHullShape().Coordinates)
if (GearOccupancy[i.x, i.y] == item)
{
ThermalMass[i.x, i.y] -= ItemManager.GetThermalMass(item.EquippableItem) / itemCellCount;
GearOccupancy[i.x, i.y] = null;
}
Mass -= ItemManager.GetMass(item.EquippableItem);
foreach (var b in item.Behaviors)
{
if (b is Weapon weapon)
_weapons.Remove(weapon);
if (b is Capacitor capacitor)
_capacitors.Remove(capacitor);
if (b is Reactor reactor)
_reactors.Remove(reactor);
if (b is Radiator heatsink)
_heatsinks.Remove(heatsink);
if (b is Shield)
Shield = null;
if (b is Cockpit)
Cockpit = null;
if (b is Sensor)
Sensor = null;
}
return item.EquippableItem;
}
private Shape GetHullShape()
{
return GetHullShape(ItemManager.GetRuntimeItem(Hull));
}
private static Shape GetHullShape(AetheriaRuntimeCatalogItem typedHull)
{
return ToShape(typedHull?.ShapeWidth ?? 1, typedHull?.ShapeHeight ?? 1, typedHull?.ShapeCells);
}
private static Shape GetHullInteriorShape(AetheriaRuntimeCatalogItem typedHull)
{
return ToShape(typedHull?.InteriorShapeWidth ?? 1, typedHull?.InteriorShapeHeight ?? 1, typedHull?.InteriorShapeCells);
}
private Shape GetItemShape(EquippableItem item)
{
return GetItemShape(ItemManager.GetRuntimeItem(item));
}
private static Shape GetItemShape(AetheriaRuntimeCatalogItem typedItem)
{
return ToShape(typedItem?.ShapeWidth ?? 1, typedItem?.ShapeHeight ?? 1, typedItem?.ShapeCells);
}
private static AetheriaRuntimeHardpoint GetHardpointAt(AetheriaRuntimeCatalogItem typedHull, int2 hullCoord)
{
if (typedHull == null) return null;
foreach (var hardpoint in typedHull.Hardpoints)
{
var hardpointShape = ToShape(hardpoint.ShapeWidth, hardpoint.ShapeHeight, hardpoint.ShapeCells);
var localCoord = hullCoord - new int2(hardpoint.PositionX, hardpoint.PositionY);
if (hardpointShape[localCoord])
return hardpoint;
}
return null;
}
private static HardpointType GetHardpointType(AetheriaRuntimeCatalogItem typedItem, HardpointType fallback)
{
return typedItem != null && Enum.TryParse(typedItem.HardpointType, out HardpointType hardpointType)
? hardpointType
: fallback;
}
private static HardpointType GetHardpointType(AetheriaRuntimeHardpoint hardpoint, HardpointType fallback)
{
return hardpoint != null && Enum.TryParse(hardpoint.Type, out HardpointType hardpointType)
? hardpointType
: fallback;
}
private static ItemRotation GetRotation(AetheriaRuntimeHardpoint hardpoint)
{
return hardpoint != null && Enum.TryParse(hardpoint.Rotation, out ItemRotation rotation)
? rotation
: ItemRotation.None;
}
2026-06-14 05:08:39 +01:00
private static HardpointData ProjectHardpoint(AetheriaRuntimeHardpoint hardpoint)
{
return new HardpointData
{
Type = GetHardpointType(hardpoint, HardpointType.Hull),
Position = new int2(hardpoint.PositionX, hardpoint.PositionY),
Shape = ToShape(hardpoint.ShapeWidth, hardpoint.ShapeHeight, hardpoint.ShapeCells),
Transform = hardpoint.Transform,
Rotation = GetRotation(hardpoint),
Armor = (float)hardpoint.Armor
};
}
private static bool IsCargoBay(AetheriaRuntimeCatalogItem typedItem)
{
return typedItem != null &&
2026-06-14 09:38:55 +01:00
(string.Equals(typedItem.Category, AetheriaRuntimeItemCategories.CargoBay, StringComparison.Ordinal) ||
string.Equals(typedItem.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal));
}
private static bool IsDockingBay(AetheriaRuntimeCatalogItem typedItem)
{
2026-06-14 09:38:55 +01:00
return typedItem != null && string.Equals(typedItem.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal);
}
private static Shape ToShape(int width, int height, IReadOnlyList<AetheriaRuntimeShapeCell> cells)
{
var shape = new Shape(Math.Max(width, 1), Math.Max(height, 1));
if (cells == null) return shape;
foreach (var cell in cells)
2026-06-21 09:37:54 +01:00
shape.SetCell(cell.X, cell.Y, true);
return shape;
}
// Check whether the given item will fit when its origin is placed at the given coordinate
private bool ItemFits(AetheriaRuntimeCatalogItem typedItem, AetheriaRuntimeCatalogItem typedHull, EquippableItem item, int2 hullCoord)
{
var hullShape = GetHullShape(typedHull);
var itemShape = GetItemShape(typedItem);
var itemHardpointType = GetHardpointType(typedItem, HardpointType.Tool);
if (itemShape.Coordinates.Length == 0) return false;
// If the given coordinate isn't even in the ship it obviously won't fit
if (!hullShape[hullCoord]) return false;
// Items without specific hardpoints on the ship can be freely rotated and placed anywhere
if (itemHardpointType == HardpointType.Tool)
{
var hullInterior = GetHullInteriorShape(typedHull);
// Check every cell of the item's shape
foreach (var i in itemShape.Coordinates)
{
// If there is any gear already occupying that space, it won't fit
2021-01-05 02:56:17 +00:00
// If there's a hardpoint there, it won't fit
// Thermal items have their own layer and do not collide with gear
var itemCoord = hullCoord + itemShape.Rotate(i, item.Rotation);
if (!hullInterior[itemCoord] ||
GetHardpointAt(typedHull, itemCoord) != null ||
GearOccupancy[itemCoord.x, itemCoord.y] != null)
return false;
}
}
else
{
var hardpoint = GetHardpointAt(typedHull, hullCoord);
// If there's no hardpoint there, it won't fit
if (hardpoint == null) return false;
// If the hardpoint type doesn't match the item, it won't fit
if (GetHardpointType(hardpoint, HardpointType.Hull) != itemHardpointType) return false;
// Items placed in hardpoints are automatically aligned to hardpoint rotation
item.Rotation = GetRotation(hardpoint);
// Inset the shapes of both item and hardpoint
var itemShapeInset = hullShape.Inset(itemShape, hullCoord, item.Rotation);
var hardpointShapeInset = hullShape.Inset(
ToShape(hardpoint.ShapeWidth, hardpoint.ShapeHeight, hardpoint.ShapeCells),
new int2(hardpoint.PositionX, hardpoint.PositionY));
// Check every cell of the hardpoint shape for existing items
2021-01-08 12:44:10 +00:00
foreach(var v in hardpointShapeInset.Coordinates)
if (GearOccupancy[v.x, v.y] != null)
return false;
// Check every cell of the item's shape
foreach (var i in itemShapeInset.Coordinates)
{
// If the hardpoint does not have a matching cell, it wont fit
if (!hardpointShapeInset[i]) return false;
// If there is any gear already occupying that space, it won't fit
if (GearOccupancy[i.x, i.y] != null) return false;
}
}
return true;
}
// Check whether the given item will fit when its origin is placed at the given coordinate on the hull
public bool ItemFits(EquippableItem item, int2 hullCoord)
{
// Don't allow equipping while deployed
if (_active) return false;
var typedItem = ItemManager.GetRuntimeItem(item);
var typedHull = ItemManager.GetRuntimeItem(Hull);
return typedItem != null && typedHull != null && ItemFits(typedItem, typedHull, item, hullCoord);
}
public bool TryFindSpace(EquippableItem item, out int2 hullCoord)
{
// Don't allow equipping while deployed
if (_active)
{
2026-06-21 09:37:54 +01:00
hullCoord = new int2(0, 0);
return false;
}
var typedItem = ItemManager.GetRuntimeItem(item);
var typedHull = ItemManager.GetRuntimeItem(Hull);
if (typedItem == null || typedHull == null)
{
2026-06-21 09:37:54 +01:00
hullCoord = new int2(0, 0);
return false;
}
var itemHardpointType = GetHardpointType(typedItem, HardpointType.Tool);
// Tools and thermal equipment can be installed anywhere on the ship
// Search the whole ship for somewhere the item will fit
if (itemHardpointType == HardpointType.Tool)
{
foreach (var hullCoord2 in GetHullInteriorShape(typedHull).Coordinates)
{
if (ItemFits(typedItem, typedHull, item, hullCoord2))
{
hullCoord = hullCoord2;
return true;
}
}
}
// Everything else has to be equipped onto a hardpoint of the same type
// Search the ship for an empty hardpoint that matches the type and shape of the item
else
{
foreach (var hardpoint in typedHull.Hardpoints)
{
if(GetHardpointType(hardpoint, HardpointType.Hull) == itemHardpointType)
{
var hardpointShape = ToShape(hardpoint.ShapeWidth, hardpoint.ShapeHeight, hardpoint.ShapeCells);
foreach (var hardpointCoord in hardpointShape.Coordinates)
{
var hullCoord2 = new int2(hardpoint.PositionX, hardpoint.PositionY) + hardpointCoord;
if (ItemFits(typedItem, typedHull, item, hullCoord2))
{
hullCoord = hullCoord2;
return true;
}
}
}
}
}
2026-06-21 09:37:54 +01:00
hullCoord = new int2(0, 0);
return false;
}
// Try to equip the given item anywhere it will fit, returns true when the item was successfully equipped
public bool TryEquip(EquippableItem item) => TryFindSpace(item, out var hullCoord) && TryEquip(item, hullCoord);
// Try to equip the given item to the given location
public bool TryEquip(EquippableItem item, int2 hullCoord)
{
// Don't allow equipping while deployed
if (_active) return false;
var typedItem = ItemManager.GetRuntimeItem(item);
var typedHull = ItemManager.GetRuntimeItem(Hull);
if (typedItem == null || typedHull == null) return false;
var itemHardpointType = GetHardpointType(typedItem, HardpointType.Tool);
var itemShape = GetItemShape(typedItem);
if (!ItemFits(typedItem, typedHull, item, hullCoord)) return false;
EquippedItem equippedItem;
if (itemHardpointType == HardpointType.Tool)
{
if(IsCargoBay(typedItem))
{
if (IsDockingBay(typedItem))
{
equippedItem = new EquippedDockingBay(ItemManager, item, hullCoord, this, $"{Name} Docking Bay {DockingBays.Count + 1}");
DockingBays.Add((EquippedDockingBay) equippedItem);
}
else
{
equippedItem = new EquippedCargoBay(ItemManager, item, hullCoord, this, $"{Name} Cargo Bay {CargoBays.Count + 1}");
CargoBays.Add((EquippedCargoBay) equippedItem);
}
}
else
{
equippedItem = new EquippedItem(ItemManager, item, hullCoord, this);
Equipment.Add(equippedItem);
}
}
else
{
equippedItem = new EquippedItem(ItemManager, item, hullCoord, this);
Equipment.Add(equippedItem);
}
foreach (var b in equippedItem.Behaviors)
{
if (b is Weapon weapon)
_weapons.Add(weapon);
if(b is Capacitor capacitor)
_capacitors.Add(capacitor);
if(b is Reactor reactor)
_reactors.Add(reactor);
if(b is Radiator heatsink)
_heatsinks.Add(heatsink);
if (b is Shield shield)
Shield = shield;
if (b is Cockpit cockpit)
Cockpit = cockpit;
if (b is Sensor sensor)
Sensor = sensor;
}
// equippedItem.OnOnline += () => ItemOnline.OnNext(equippedItem);
// equippedItem.OnOffline += () => ItemOffline.OnNext(equippedItem);
foreach (var i in itemShape.Coordinates)
{
var occupiedCoord = hullCoord + itemShape.Rotate(i, item.Rotation);
// TODO: Track thermal mass of cargo bay contents as reactive property
ThermalMass[occupiedCoord.x, occupiedCoord.y] += ItemManager.GetThermalMass(item) / itemShape.Coordinates.Length;
GearOccupancy[occupiedCoord.x, occupiedCoord.y] = equippedItem;
}
Mass += ItemManager.GetMass(item);
_orderedEquipment = Equipment.OrderBy(x => x.SortPosition).ToArray();
return true;
}
public EquippedDockingBay TryDock(Ship ship)
{
//if (!IsDockingPermitted(ship.GetFactionRelationship(Faction), CurrentSecurityLevel.Value)) return null;
var bay = DockingBays.FirstOrDefault(x => x.DockedShip == null);
if (bay != null)
{
bay.DockedShip = ship;
ship.SetParent(this);
Zone.Entities.Remove(ship);
ship.Deactivate();
ship.Docked.OnNext(this);
}
return bay;
}
public bool TryUndock(Ship ship)
{
var bay = DockingBays.FirstOrDefault(x => x.DockedShip == ship);
if (bay == null)
{
ItemManager.Log($"Ship {ship.Name} attempted to undock from {Name}, but it was not docked!");
return false;
}
if (bay.Cargo.Any())
return false;
bay.DockedShip = null;
ship.RemoveParent();
Zone.Entities.Add(ship);
ship.Activate();
return true;
}
public bool CanConsumeEnergy(float energy)
{
var capEnergy = _capacitors.Sum(cap => cap.Charge);
int onlineReactors = _reactors.Count(reactor=>reactor.Item.Online.Value);
return capEnergy > energy || onlineReactors > 0;
}
public bool TryConsumeEnergy(float energy)
{
if (energy < .01f) return true;
int chargedCapacitors;
do
{
chargedCapacitors = _capacitors.Count(capacitor => capacitor.Charge > .01f);
var chargeToRemove = energy;
foreach (var cap in _capacitors)
{
if(cap.Charge > 0.01f)
{
2026-06-21 09:37:54 +01:00
var chargeRemoved = Math.Min(chargeToRemove / chargedCapacitors, cap.Charge);
cap.AddCharge(-chargeRemoved);
energy -= chargeRemoved;
}
}
} while (chargedCapacitors > 0 && energy > .01f);
if (energy < .01f) return true;
int onlineReactors = _reactors.Count(reactor=>reactor.Item.Online.Value);
foreach (var reactor in _reactors)
{
if (reactor.Item.Online.Value)
{
reactor.ConsumeEnergy(energy / onlineReactors);
}
}
return onlineReactors > 0;
}
private void AddChild(Entity entity)
{
Mass += entity.Mass;
Children.Add(entity);
}
private void RemoveChild(Entity entity)
{
Mass -= entity.Mass;
Children.Remove(entity);
}
public void SetParent(Entity parent)
{
Parent = parent;
parent.AddChild(this);
}
public void RemoveParent()
{
if (Parent == null)
return;
Parent.RemoveChild(this);
Parent = null;
}
public T GetBehavior<T>() where T : Behavior
{
foreach (var equippedItem in Equipment)
if(equippedItem.Behaviors != null)
foreach (var behavior in equippedItem.Behaviors)
if (behavior is T b)
return b;
return null;
}
public IEnumerable<T> GetBehaviors<T>() where T : Behavior
{
foreach (var equippedItem in Equipment)
if(equippedItem.Behaviors != null)
foreach (var behavior in equippedItem.Behaviors)
if (behavior is T b)
yield return b;
}
public virtual void Update(float delta)
{
2026-06-21 09:37:54 +01:00
TargetRange = Target.Value == null ? -1 : CultMath.math.length(CultPosition - Target.Value.CultPosition);
2026-06-21 09:37:54 +01:00
var localSecurityLevel = Zone.GetSecurityLevel(CultPositionXZ);
if (CurrentSecurityLevel.Value != localSecurityLevel) CurrentSecurityLevel.Value = localSecurityLevel;
foreach (var v in VisibilitySources.Keys.ToArray())
{
VisibilitySources[v] = AetheriaMath.Decay(VisibilitySources[v], ItemManager.GameplaySettings.VisibilityDecay, delta);
if (VisibilitySources[v] < 0.1f) VisibilitySources.Remove(v);
}
UpdateTemperature(delta);
foreach (var item in _orderedEquipment) item.UpdatePerformance();
if (_active)
{
foreach (var entity in Zone.Entities)
{
var previousHostility = EntityHostility[entity];
var newHostility = IsHostileTo(entity);
if (newHostility != previousHostility)
EntityHostility[entity] = newHostility;
}
if(Cockpit != null)
{
var cockpitTemp = Cockpit.Temperature;
if (cockpitTemp > ItemManager.GameplaySettings.HeatstrokeTemperature)
{
var previous = Heatstroke;
2026-06-21 09:37:54 +01:00
Heatstroke = AetheriaScalar.Saturate(
Heatstroke +
2026-06-21 09:37:54 +01:00
MathF.Pow(cockpitTemp - ItemManager.GameplaySettings.HeatstrokeTemperature, ItemManager.GameplaySettings.HeatstrokeExponent) *
ItemManager.GameplaySettings.HeatstrokeMultiplier * delta);
if(previous < ItemManager.GameplaySettings.SevereHeatstrokeRiskThreshold && Heatstroke > ItemManager.GameplaySettings.SevereHeatstrokeRiskThreshold)
HeatstrokeRisk.OnNext(Unit.Default);
if(Heatstroke > .99)
{
HeatstrokeDeath.OnNext(Unit.Default);
Deactivate();
}
}
else
{
2026-06-21 09:37:54 +01:00
Heatstroke = AetheriaScalar.Saturate(Heatstroke - ItemManager.GameplaySettings.HeatstrokeRecoverySpeed * delta);
}
if (cockpitTemp < ItemManager.GameplaySettings.HypothermiaTemperature)
{
var previous = Hypothermia;
2026-06-21 09:37:54 +01:00
Hypothermia = AetheriaScalar.Saturate(
Hypothermia +
2026-06-21 09:37:54 +01:00
MathF.Pow(ItemManager.GameplaySettings.HypothermiaTemperature - cockpitTemp, ItemManager.GameplaySettings.HypothermiaExponent) *
ItemManager.GameplaySettings.HypothermiaMultiplier * delta);
if(previous < ItemManager.GameplaySettings.SevereHeatstrokeRiskThreshold && Heatstroke > ItemManager.GameplaySettings.SevereHeatstrokeRiskThreshold)
HypothermiaRisk.OnNext(Unit.Default);
if(Hypothermia > .99)
{
HypothermiaDeath.OnNext(Unit.Default);
Deactivate();
}
}
else
{
2026-06-21 09:37:54 +01:00
Hypothermia = AetheriaScalar.Saturate(Hypothermia - ItemManager.GameplaySettings.HypothermiaRecoverySpeed * delta);
}
}
for (var i = 0; i < _activeConsumables.Count; i++)
{
_activeConsumables[i].Update(delta);
if(_activeConsumables[i].RemainingDuration < 0) _activeConsumables.RemoveAt(i--);
}
foreach (var equippedItem in _orderedEquipment)
{
equippedItem.Update(delta);
}
foreach (var message in Messages.Keys.ToArray())
{
Messages[message] = Messages[message] - delta;
if (Messages[message] < 0)
Messages.Remove(message);
}
}
foreach(var child in Children)
child.Update(delta);
if (Parent != null)
{
2026-06-21 09:37:54 +01:00
CultPosition = Parent.CultPosition;
CultVelocity = Parent.CultVelocity;
}
2026-06-21 09:37:54 +01:00
else CultPositionY = Zone.GetHeight(CultPositionXZ) + (float)(ItemManager.GetRuntimeItem(Hull)?.HullGridOffset ?? 0);
}
private void UpdateTemperature(float delta)
{
2026-06-14 05:08:39 +01:00
var typedHull = ItemManager.GetRuntimeItem(Hull);
var hullShape = GetHullShape(typedHull);
var hullInterior = GetHullInteriorShape(typedHull);
var hullConductivity = (float)(typedHull?.Conductivity ?? 1);
MaxTemp = Single.MinValue;
MinTemp = Single.MaxValue;
2026-06-14 05:08:39 +01:00
//float[,] newTemp = new float[hullShape.Width,hullShape.Height];
var radiation = 0f;
2026-06-14 05:08:39 +01:00
foreach (var v in hullShape.Coordinates)
{
var temp = Temperature[v.x, v.y];
var totalTemp = temp / ItemManager.GameplaySettings.HeatConductionMultiplier;
var totalConductivity = 1f / ItemManager.GameplaySettings.HeatConductionMultiplier;
2026-06-21 09:37:54 +01:00
if (hullShape.GetCell(v.x - 1, v.y))
{
var conductivity = (GearOccupancy[v.x, v.y]?.Conductivity ?? 1) *
(GearOccupancy[v.x - 1, v.y]?.Conductivity ?? 1) *
2026-06-14 05:08:39 +01:00
(HullConductivity[v.x - 1, v.y].x ? hullConductivity : 1 / hullConductivity) *
(ThermalMass[v.x - 1, v.y] / ThermalMass[v.x, v.y]);
totalConductivity += conductivity;
totalTemp += Temperature[v.x - 1, v.y] * conductivity;
}
2026-06-21 09:37:54 +01:00
if (hullShape.GetCell(v.x + 1, v.y))
{
var conductivity = (GearOccupancy[v.x, v.y]?.Conductivity ?? 1) *
(GearOccupancy[v.x + 1, v.y]?.Conductivity ?? 1) *
2026-06-14 05:08:39 +01:00
(HullConductivity[v.x, v.y].x ? hullConductivity : 1 / hullConductivity) *
(ThermalMass[v.x + 1, v.y] / ThermalMass[v.x, v.y]);
totalConductivity += conductivity;
totalTemp += Temperature[v.x + 1, v.y] * conductivity;
}
2026-06-21 09:37:54 +01:00
if (hullShape.GetCell(v.x, v.y - 1))
{
var conductivity = (GearOccupancy[v.x, v.y]?.Conductivity ?? 1) *
(GearOccupancy[v.x, v.y - 1]?.Conductivity ?? 1) *
2026-06-14 05:08:39 +01:00
(HullConductivity[v.x, v.y - 1].y ? hullConductivity : 1 / hullConductivity) *
(ThermalMass[v.x, v.y - 1] / ThermalMass[v.x, v.y]);
totalConductivity += conductivity;
totalTemp += Temperature[v.x, v.y - 1] * conductivity;
}
2026-06-21 09:37:54 +01:00
if (hullShape.GetCell(v.x, v.y + 1))
{
var conductivity = (GearOccupancy[v.x, v.y]?.Conductivity ?? 1) *
(GearOccupancy[v.x, v.y + 1]?.Conductivity ?? 1) *
2026-06-14 05:08:39 +01:00
(HullConductivity[v.x, v.y].y ? hullConductivity : 1 / hullConductivity) *
(ThermalMass[v.x, v.y + 1] / ThermalMass[v.x, v.y]);
totalConductivity += conductivity;
totalTemp += Temperature[v.x, v.y + 1] * conductivity;
}
NewTemperature[v.x, v.y] = totalTemp / totalConductivity;
var r = 0f;
// For all cells on the border of the entity, radiate some heat into space, increasing the visibility of the ship
2026-06-14 05:08:39 +01:00
if (Parent==null && !hullInterior[v])
{
2026-06-21 09:37:54 +01:00
var rad = MathF.Pow(NewTemperature[v.x, v.y], ItemManager.GameplaySettings.HeatRadiationExponent) *
ItemManager.GameplaySettings.HeatRadiationMultiplier;
NewTemperature[v.x, v.y] -= rad * delta;
r += rad;
}
radiation += r;
if(float.IsNaN(NewTemperature[v.x, v.y]) || NewTemperature[v.x, v.y] < 0)
ItemManager.Log("HOUSTON, WE HAVE A PROBLEM!");
if (NewTemperature[v.x, v.y] < MinTemp)
MinTemp = NewTemperature[v.x, v.y];
if (NewTemperature[v.x, v.y] > MaxTemp)
MaxTemp = NewTemperature[v.x, v.y];
}
VisibilitySources[this] = radiation;
var swap = Temperature;
Temperature = NewTemperature;
NewTemperature = swap;
}
public void SetMessage(string message)
{
Messages[message] = ItemManager.GameplaySettings.MessageDuration;
}
}
public class ConsumableItemEffect
{
public float RemainingDuration { get; private set; }
public Entity Entity { get; }
public ConsumableItem Item { get; }
public Behavior[] Behaviors { get; }
public AetheriaRuntimeCatalogItem RuntimeItem { get; }
public float Duration => Math.Max(_duration, float.Epsilon);
private readonly float _duration;
private readonly BezierCurve _effectiveness;
public ConsumableItemEffect(ConsumableItem item, Entity entity)
: this(item, entity, null, null)
{
}
public ConsumableItemEffect(ConsumableItem item, Entity entity, float? remainingDuration, float? duration)
{
Item = item;
Entity = entity;
RuntimeItem = entity.ItemManager.GetRuntimeItem(item);
_duration = duration.GetValueOrDefault(RuntimeItem?.Duration > 0 ? (float)RuntimeItem.Duration : 0);
2026-06-14 07:52:18 +01:00
_effectiveness = CreateEffectivenessCurve(RuntimeItem);
RemainingDuration = remainingDuration.HasValue
2026-06-21 09:37:54 +01:00
? AetheriaScalar.Clamp(remainingDuration.Value, 0, Duration)
: _duration;
Behaviors = entity.ItemManager.CreateRuntimeBehaviors(this);
}
public void Update(float delta)
{
foreach (var behavior in Behaviors)
if(behavior is IAlwaysUpdatedBehavior alwaysUpdatedBehavior) alwaysUpdatedBehavior.Update(delta);
foreach (var behavior in Behaviors)
{
if (!behavior.Execute(delta))
break;
}
RemainingDuration -= delta;
}
public float Evaluate(PerformanceStat stat)
{
2026-06-21 09:37:54 +01:00
if (stat == null)
return 0;
var duration = Math.Max(_duration, float.Epsilon);
var effectiveness = _effectiveness.Evaluate((duration - RemainingDuration) / duration);
2026-06-21 09:37:54 +01:00
var result = stat.EvaluateConsumableBaseline(new StatEvaluationContext(
quality: Item.Quality,
durability: effectiveness,
heat: 1));
if (float.IsNaN(result))
return stat.Min;
return result;
}
2026-06-21 09:37:54 +01:00
public float Evaluate(PerformanceStat stat, StatConditionMask condition, float value)
{
if (stat == null)
return 0;
if ((GetDependencyMask(stat) & condition) == 0)
return Evaluate(stat);
var duration = Math.Max(_duration, float.Epsilon);
var effectiveness = _effectiveness.Evaluate((duration - RemainingDuration) / duration);
var context = new StatEvaluationContext(
quality: Item.Quality,
durability: effectiveness,
heat: 1).WithCondition(condition, value);
var result = stat.EvaluateRecipeOrLegacy(
context,
durabilityExponent: 1,
thermalExponent: 0,
includeHeat: false);
if (float.IsNaN(result))
return stat.Min;
return result;
}
public float Evaluate(
PerformanceStat stat,
StatConditionMask firstCondition,
float firstValue,
StatConditionMask secondCondition,
float secondValue)
{
if (stat == null)
return 0;
var dependencyMask = GetDependencyMask(stat);
if ((dependencyMask & (firstCondition | secondCondition)) == 0)
return Evaluate(stat);
var duration = Math.Max(_duration, float.Epsilon);
var effectiveness = _effectiveness.Evaluate((duration - RemainingDuration) / duration);
var context = new StatEvaluationContext(
quality: Item.Quality,
durability: effectiveness,
heat: 1)
.WithCondition(firstCondition, firstValue)
.WithCondition(secondCondition, secondValue);
var result = stat.EvaluateRecipeOrLegacy(
context,
durabilityExponent: 1,
thermalExponent: 0,
includeHeat: false);
if (float.IsNaN(result))
return stat.Min;
return result;
}
private static StatConditionMask GetDependencyMask(PerformanceStat stat)
{
return stat?.GetDependencyMask(includeHeat: false) ?? StatConditionMask.None;
}
2026-06-14 07:52:18 +01:00
private static BezierCurve CreateEffectivenessCurve(AetheriaRuntimeCatalogItem item)
{
if (item?.EffectivenessCurveKeys != null && item.EffectivenessCurveKeys.Count > 0)
{
2026-06-21 09:37:54 +01:00
return BezierCurve.FromKeys(
item.EffectivenessCurveKeys
.Select(key => (
(float)key.Time,
(float)key.Value,
(float)key.InTangent,
(float)key.OutTangent))
2026-06-21 09:37:54 +01:00
);
}
2026-06-21 09:37:54 +01:00
return BezierCurve.FromKeys(new[] { (0f, 1f, 0f, 0f), (1f, 1f, 0f, 0f) });
}
}
public class EquippedItem
{
public int SortPosition;
public EquippableItem EquippableItem;
public int2 Position;
private bool _thermalOnline;
private bool _durabilityOnline;
2021-04-30 06:39:57 +01:00
public WwiseMetaSoundBank SoundBank;
public Behavior[] Behaviors { get; }
public Dictionary<int, BehaviorGroup> BehaviorGroups { get; }
public float Conductivity { get; }
public float MaxDurability { get; }
public float ThermalResilience { get; }
public float ThermalPerformance { get; private set; }
public float ThermalExponent { get; }
public float DurabilityPerformance { get; private set; }
public float DurabilityExponent { get; }
public float Wear { get; private set; }
public Shape InsetShape { get; }
public Entity Entity { get; }
public AetheriaRuntimeCatalogItem RuntimeItem { get; }
public ReactiveProperty<bool> ThermalOnline { get; } = new ReactiveProperty<bool>(false);
public ReactiveProperty<bool> DurabilityOnline { get; } = new ReactiveProperty<bool>(false);
public ReadOnlyReactiveProperty<bool> Online { get; }
public ReactiveProperty<bool> Enabled { get; } = new ReactiveProperty<bool>(true);
public ReadOnlyReactiveProperty<bool> Active { get; }
public ItemManager ItemManager { get; }
2021-04-30 06:39:57 +01:00
public Subject<uint> AudioEvents { get; } = new Subject<uint>();
public Subject<(uint id, float v)> AudioParameters { get; } = new Subject<(uint id, float v)>();
public Dictionary<uint, float> AudioParameterValues { get; } = new Dictionary<uint, float>();
private readonly BezierCurve _thermalPerformanceCurve;
private readonly EquippedItemAudioStatBinding[] _audioStats;
2026-06-21 09:37:54 +01:00
private readonly Dictionary<PerformanceStat, EquippedItemBaseStatCacheEntry> _baseStatCache =
new Dictionary<PerformanceStat, EquippedItemBaseStatCacheEntry>();
private readonly Dictionary<PerformanceStat, EquippedItemStatCacheEntry> _statCache =
new Dictionary<PerformanceStat, EquippedItemStatCacheEntry>();
private int _qualityVersion;
private int _durabilityVersion;
private int _thermalVersion;
private int _chargeVersion;
private int _ammoVersion;
private int _rangeVersion;
private int _integrityVersion;
private int _pilotSkillVersion;
private int _environmentVersion;
private float _lastQuality;
private float _statCharge;
private float _statAmmo;
private float _statRange;
private float _statIntegrity;
private float _statPilotSkill;
private float _statEnvironment;
private float oldTemperature;
public float Temperature
{
get
{
float sum = 0;
foreach (var x in InsetShape.Coordinates) sum += Entity.Temperature[x.x, x.y];
return sum/InsetShape.Coordinates.Length;
}
}
2021-04-30 06:39:57 +01:00
public void FireAudioEvent(uint eventId, bool skipVerify = false)
{
if(SoundBank != null && (skipVerify || SoundBank.IncludedEvents.Any(o => o.Id == eventId)))
AudioEvents.OnNext(eventId);
}
public void FireAudioEvent(WwiseSoundBinding soundBinding)
{
FireAudioEvent(soundBinding.PlayEvent);
}
public void PlaySound(WwiseLoopingSoundBinding soundBinding)
{
FireAudioEvent(soundBinding.PlayEvent);
}
public void StopSound(WwiseLoopingSoundBinding soundBinding)
{
FireAudioEvent(soundBinding.StopEvent);
}
public void FireAudioEvent(WeaponAudioEvent weaponAudioEvent)
{
if (SoundBank == null) return;
var eventObject = SoundBank.GetEvent(weaponAudioEvent);
if (eventObject == null)
{
ItemManager.Log($"Attempted to trigger {Enum.GetName(typeof(WeaponAudioEvent), weaponAudioEvent)} weapon audio event, but the soundbank doesn't support it!");
return;
}
FireAudioEvent(eventObject.Id, true);
}
public void FireAudioEvent(ChargedWeaponAudioEvent weaponAudioEvent)
{
if (SoundBank == null) return;
var eventObject = SoundBank.GetEvent(weaponAudioEvent);
if (eventObject == null)
{
ItemManager.Log($"Attempted to trigger {Enum.GetName(typeof(ChargedWeaponAudioEvent), weaponAudioEvent)} weapon audio event, but the soundbank doesn't support it!");
return;
}
FireAudioEvent(eventObject.Id, true);
}
public void SetAudioParameter(uint id, float v, bool skipVerify = false)
{
if (SoundBank != null && (skipVerify || SoundBank.GameParameters.Any(o => o.Id == id)))
{
AudioParameterValues[id] = v;
AudioParameters.OnNext((id, v));
}
}
// public void SetAudioParameter(WwiseParameterBinding binding)
// {
// FireAudioEvent(binding.Parameter);
// }
public void SetAudioParameter(SpecialAudioParameter p, float v)
{
if (SoundBank == null) return;
var metaObject = SoundBank.GetParameter(p);
if (metaObject == null)
{
ItemManager.Log($"Attempted to set {Enum.GetName(typeof(ChargedWeaponAudioEvent), p)} audio parameter, but the soundbank doesn't support it!");
return;
}
SetAudioParameter(metaObject.Id, v, true);
}
public EquippedItem(ItemManager itemManager, EquippableItem item, int2 position, Entity entity)
{
ItemManager = itemManager;
Entity = entity;
EquippableItem = item;
Position = position;
RuntimeItem = ItemManager.GetRuntimeItem(item);
2026-06-14 07:52:18 +01:00
Conductivity = RuntimeItem != null ? (float)RuntimeItem.Conductivity : 0;
MaxDurability = RuntimeItem?.Durability > 0 ? (float)RuntimeItem.Durability : Math.Max(item.Durability, 1f);
2026-06-14 07:52:18 +01:00
ThermalResilience = RuntimeItem?.ThermalResilience > 0 ? (float)RuntimeItem.ThermalResilience : 1;
_thermalPerformanceCurve = CreateThermalPerformanceCurve(RuntimeItem);
2026-06-14 07:52:18 +01:00
_audioStats = CreateAudioStatBindings(RuntimeItem);
2026-06-21 09:37:54 +01:00
ThermalExponent = AetheriaScalar.Lerp(
ItemManager.GameplaySettings.ThermalQualityMin,
ItemManager.GameplaySettings.ThermalQualityMax,
2026-06-21 09:37:54 +01:00
MathF.Pow(item.Quality, ItemManager.GameplaySettings.ThermalQualityExponent));
DurabilityExponent = AetheriaScalar.Lerp(
ItemManager.GameplaySettings.DurabilityQualityMin,
ItemManager.GameplaySettings.DurabilityQualityMax,
2026-06-21 09:37:54 +01:00
MathF.Pow(item.Quality, ItemManager.GameplaySettings.DurabilityQualityExponent));
_lastQuality = item.Quality;
var hullShape = ItemManager.GetRuntimeShape(entity.Hull);
var itemShape = ItemManager.GetRuntimeShape(item);
InsetShape = hullShape.Inset(itemShape, position, item.Rotation);
if (Entity.Temperature != null) oldTemperature = Temperature;
Online = new ReadOnlyReactiveProperty<bool>(ThermalOnline
.CombineLatest(DurabilityOnline, (thermal, durability) => thermal && durability).DistinctUntilChanged());
Active = new ReadOnlyReactiveProperty<bool>(Enabled
.CombineLatest(Online, (enabled, online) => enabled && online).DistinctUntilChanged());
Behaviors = ItemManager.CreateRuntimeBehaviors(this);
BehaviorGroups = Behaviors
.GroupBy(b => b.Group)
.OrderBy(g => g.Key)
.ToDictionary(g => g.Key, g => new BehaviorGroup
{
Behaviors = g.ToArray()
});
foreach (var behavior in Behaviors)
{
if (behavior is IOrderedBehavior orderedBehavior)
SortPosition = orderedBehavior.Order;
if(behavior is IPopulationAssignment populationAssignment)
entity.PopulationAssignments.Add(populationAssignment);
}
}
2026-06-21 09:37:54 +01:00
internal void RegisterPerformanceStat(string behaviorKind, string statName, PerformanceStat stat)
{
if (stat == null || string.IsNullOrWhiteSpace(statName))
return;
if (!_statCache.TryGetValue(stat, out var entry))
{
_statCache[stat] = new EquippedItemStatCacheEntry(stat, GetDependencyMask(stat));
}
else
{
entry.Stat = stat;
entry.DependencyMask = GetDependencyMask(stat);
entry.Dirty = true;
}
}
public float Evaluate(string behaviorKind, string statName, PerformanceStat stat)
{
RegisterPerformanceStat(behaviorKind, statName, stat);
return Evaluate(stat);
}
public float Evaluate(PerformanceStat stat)
{
2026-06-21 09:37:54 +01:00
if (stat == null)
{
return 0;
}
if (_statCache.TryGetValue(stat, out var entry))
{
return EvaluateCachedStat(entry);
}
return EvaluateUncached(stat);
}
public float Evaluate(PerformanceStat stat, StatConditionMask condition, float value)
{
if (stat == null)
return 0;
var dependencyMask = GetDependencyMask(stat);
if ((dependencyMask & condition) == 0)
return Evaluate(stat);
return EvaluateWithContext(stat, CreateStatEvaluationContext().WithCondition(condition, value));
}
public float Evaluate(
PerformanceStat stat,
StatConditionMask firstCondition,
float firstValue,
StatConditionMask secondCondition,
float secondValue)
{
if (stat == null)
return 0;
2026-06-21 09:37:54 +01:00
var dependencyMask = GetDependencyMask(stat);
if ((dependencyMask & (firstCondition | secondCondition)) == 0)
return Evaluate(stat);
2026-06-21 09:37:54 +01:00
var context = CreateStatEvaluationContext()
.WithCondition(firstCondition, firstValue)
.WithCondition(secondCondition, secondValue);
return EvaluateWithContext(stat, context);
}
2026-06-21 09:37:54 +01:00
private float EvaluateCachedStat(EquippedItemStatCacheEntry entry)
{
var stamp = BuildCacheStamp(entry.DependencyMask);
if (!entry.Dirty && entry.InputStamp == stamp)
{
return entry.Value;
}
var value = EvaluateUncached(entry.Stat);
entry.Value = value;
entry.InputStamp = stamp;
entry.Dirty = false;
return value;
}
private float EvaluateUncached(PerformanceStat stat)
{
var dependencyMask = GetDependencyMask(stat);
var stamp = BuildCacheStamp(dependencyMask);
if (!_baseStatCache.TryGetValue(stat, out var baseEntry) || baseEntry.InputStamp != stamp)
{
float baseValue;
baseValue = stat.EvaluateRecipeOrLegacy(
CreateStatEvaluationContext(),
DurabilityExponent,
ThermalExponent,
includeHeat: true);
_baseStatCache[stat] = new EquippedItemBaseStatCacheEntry(baseValue, stamp);
}
var cachedBaseValue = _baseStatCache[stat].Value;
var result = stat.ApplyEntityModifiers(Entity, cachedBaseValue);
if (float.IsNaN(result))
return stat.Min;
return result;
}
2026-06-21 09:37:54 +01:00
private float EvaluateWithContext(PerformanceStat stat, in StatEvaluationContext context)
{
var baseValue = stat.EvaluateRecipeOrLegacy(
context,
DurabilityExponent,
ThermalExponent,
includeHeat: true);
var result = stat.ApplyEntityModifiers(Entity, baseValue);
return float.IsNaN(result) ? stat.Min : result;
}
internal void SetStatModifier(
PerformanceStat stat,
Behavior source,
StatModifierType type,
float value)
{
if (stat == null || source == null)
return;
var modifiers = type == StatModifierType.Constant
? stat.GetConstantModifiers(Entity)
: stat.GetScaleModifiers(Entity);
if (modifiers.TryGetValue(source, out var current) && MathF.Abs(current - value) < 0.00001f)
return;
modifiers[source] = value;
InvalidateStat(stat);
}
internal void RemoveStatModifier(PerformanceStat stat, Behavior source, StatModifierType type)
{
if (stat == null || source == null)
return;
var hasModifiers = type == StatModifierType.Constant
? stat.TryGetConstantModifiers(Entity, out var modifiers)
: stat.TryGetScaleModifiers(Entity, out modifiers);
if (hasModifiers && modifiers.Remove(source))
InvalidateStat(stat);
}
public void SetStatConditionValue(StatConditionMask condition, float value)
{
switch (condition)
{
case StatConditionMask.Charge:
SetTrackedStatCondition(ref _statCharge, ref _chargeVersion, value);
break;
case StatConditionMask.Ammo:
SetTrackedStatCondition(ref _statAmmo, ref _ammoVersion, value);
break;
case StatConditionMask.Range:
SetTrackedStatCondition(ref _statRange, ref _rangeVersion, value);
break;
case StatConditionMask.Integrity:
SetTrackedStatCondition(ref _statIntegrity, ref _integrityVersion, value);
break;
case StatConditionMask.PilotSkill:
SetTrackedStatCondition(ref _statPilotSkill, ref _pilotSkillVersion, value);
break;
case StatConditionMask.Environment:
SetTrackedStatCondition(ref _statEnvironment, ref _environmentVersion, value);
break;
}
}
public void AddHeat(float heat, bool ignoreThermalMass = false)
{
foreach(var hullCoord in InsetShape.Coordinates)
Entity.AddHeat(hullCoord, heat / InsetShape.Coordinates.Length, ignoreThermalMass);
}
public void UpdatePerformance()
{
var temp = Temperature;
2026-06-21 09:37:54 +01:00
var nextThermalPerformance = EvaluateThermalPerformance(temp);
var deltaTemp = MathF.Abs(temp - oldTemperature);
var nextDurabilityPerformance = EquippableItem.Durability / MaxDurability;
var nextQuality = EquippableItem.Quality;
if (MathF.Abs(nextThermalPerformance - ThermalPerformance) > 0.00001f)
{
_thermalVersion++;
}
if (MathF.Abs(nextDurabilityPerformance - DurabilityPerformance) > 0.00001f)
{
_durabilityVersion++;
}
if (MathF.Abs(nextQuality - _lastQuality) > 0.00001f)
{
_qualityVersion++;
}
ThermalPerformance = nextThermalPerformance;
DurabilityPerformance = nextDurabilityPerformance;
_lastQuality = nextQuality;
var performanceThreshold = Entity.Settings.ShutdownPerformance;
2026-06-21 09:37:54 +01:00
Wear = (1 - MathF.Pow(ThermalPerformance,
(1 - MathF.Pow(EquippableItem.Quality, ItemManager.GameplaySettings.QualityWearExponent)) *
ItemManager.GameplaySettings.ThermalWearExponent) +
deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent
) * MaxDurability / ThermalResilience;
ThermalOnline.Value = ThermalPerformance > performanceThreshold || Entity.OverrideShutdown && EquippableItem.OverrideShutdown;
DurabilityOnline.Value = EquippableItem.Durability > .01f;
oldTemperature = temp;
}
2026-06-21 09:37:54 +01:00
private void InvalidateStat(PerformanceStat stat)
{
if (stat != null && _statCache.TryGetValue(stat, out var entry))
entry.Dirty = true;
}
private StatConditionMask GetDependencyMask(PerformanceStat stat)
{
return stat?.GetDependencyMask(includeHeat: true) ?? StatConditionMask.None;
}
private int BuildCacheStamp(StatConditionMask dependencyMask)
{
unchecked
{
var stamp = 17;
if ((dependencyMask & StatConditionMask.Quality) != 0)
stamp = stamp * 31 + _qualityVersion;
if ((dependencyMask & StatConditionMask.Durability) != 0)
stamp = stamp * 31 + _durabilityVersion;
if ((dependencyMask & StatConditionMask.Heat) != 0)
stamp = stamp * 31 + _thermalVersion;
if ((dependencyMask & StatConditionMask.Charge) != 0)
stamp = stamp * 31 + _chargeVersion;
if ((dependencyMask & StatConditionMask.Ammo) != 0)
stamp = stamp * 31 + _ammoVersion;
if ((dependencyMask & StatConditionMask.Range) != 0)
stamp = stamp * 31 + _rangeVersion;
if ((dependencyMask & StatConditionMask.Integrity) != 0)
stamp = stamp * 31 + _integrityVersion;
if ((dependencyMask & StatConditionMask.PilotSkill) != 0)
stamp = stamp * 31 + _pilotSkillVersion;
if ((dependencyMask & StatConditionMask.Environment) != 0)
stamp = stamp * 31 + _environmentVersion;
return stamp;
}
}
private StatEvaluationContext CreateStatEvaluationContext()
{
return new StatEvaluationContext(
quality: EquippableItem.Quality,
durability: DurabilityPerformance,
heat: ThermalPerformance,
charge: _statCharge,
ammo: _statAmmo,
range: _statRange,
integrity: _statIntegrity,
pilotSkill: _statPilotSkill,
environment: _statEnvironment);
}
private static void SetTrackedStatCondition(ref float current, ref int version, float next)
{
if (MathF.Abs(current - next) <= 0.00001f)
return;
current = next;
version++;
}
private float EvaluateThermalPerformance(float temperature)
{
if (_thermalPerformanceCurve == null ||
RuntimeItem == null ||
RuntimeItem.MaximumTemperature <= RuntimeItem.MinimumTemperature)
{
2026-06-14 07:52:18 +01:00
return 1;
}
2026-06-21 09:37:54 +01:00
var t = AetheriaScalar.Unlerp((float)RuntimeItem.MinimumTemperature, (float)RuntimeItem.MaximumTemperature, temperature);
return AetheriaScalar.Saturate(_thermalPerformanceCurve.Evaluate(t));
}
private static BezierCurve CreateThermalPerformanceCurve(AetheriaRuntimeCatalogItem item)
{
if (item?.ThermalPerformanceCurveKeys == null || item.ThermalPerformanceCurveKeys.Count == 0)
{
return null;
}
2026-06-21 09:37:54 +01:00
return BezierCurve.FromKeys(
item.ThermalPerformanceCurveKeys
.Select(key => ((float)key.Time, (float)key.Value, (float)key.InTangent, (float)key.OutTangent)));
}
public void Update(float delta)
{
foreach (var audioStat in _audioStats)
2021-04-30 06:39:57 +01:00
{
SetAudioParameter(audioStat.Parameter, Evaluate(audioStat.Stat));
}
if (Active.Value)
{
foreach (var group in BehaviorGroups.Values)
{
foreach (var behavior in group.Behaviors)
{
if (!behavior.Execute(delta))
break;
}
}
}
foreach (var behavior in Behaviors)
if(behavior is IAlwaysUpdatedBehavior alwaysUpdatedBehavior) alwaysUpdatedBehavior.Update(delta);
}
public T GetBehavior<T>() where T : class
{
foreach (var behavior in Behaviors)
if (behavior is T b)
return b;
return null;
}
2026-06-14 07:52:18 +01:00
private static EquippedItemAudioStatBinding[] CreateAudioStatBindings(AetheriaRuntimeCatalogItem item)
{
if (item?.AudioStats != null)
{
return item.AudioStats
.Select(audioStat => new EquippedItemAudioStatBinding(
audioStat.Parameter,
CreatePerformanceStat(audioStat.Stat)))
.ToArray();
}
2026-06-14 07:52:18 +01:00
return Array.Empty<EquippedItemAudioStatBinding>();
}
private static PerformanceStat CreatePerformanceStat(AetheriaRuntimePerformanceStat stat)
{
return new PerformanceStat
{
Min = (float)stat.Min,
Max = (float)stat.Max,
HeatExponentMultiplier = (float)stat.HeatExponentMultiplier,
DurabilityExponentMultiplier = (float)stat.DurabilityExponentMultiplier,
2026-06-21 09:37:54 +01:00
QualityExponent = (float)stat.QualityExponent,
Recipe = CreateStatRecipe(stat.Recipe)
};
}
private static StatRecipe CreateStatRecipe(AetheriaRuntimeStatRecipe recipe)
{
if (recipe == null)
return null;
return new StatRecipe
{
BaseValue = (float)recipe.BaseValue,
Modifiers = recipe.Modifiers
.Select(CreateStatRecipeModifier)
.Where(modifier => modifier != null)
.ToArray()
};
}
2026-06-21 09:37:54 +01:00
private static StatRecipeModifier CreateStatRecipeModifier(AetheriaRuntimeStatRecipeModifier modifier)
{
if (modifier == null)
return null;
return new StatRecipeModifier
{
Condition = StatRecipeTokens.ToConditionMask(modifier.Condition),
Operation = StatRecipeTokens.ToModifierOperation(modifier.Operation),
Amount = (float)modifier.Amount,
Curve = CreateCurve(modifier.CurveKeys),
Enabled = modifier.Enabled
};
}
private static BezierCurve CreateCurve(IReadOnlyList<AetheriaRuntimeCurveKey> keys)
{
if (keys == null || keys.Count == 0)
return null;
return BezierCurve.FromKeys(
keys.Select(key => ((float)key.Time, (float)key.Value, (float)key.InTangent, (float)key.OutTangent)));
}
private readonly struct EquippedItemAudioStatBinding
{
public EquippedItemAudioStatBinding(uint parameter, PerformanceStat stat)
{
Parameter = parameter;
Stat = stat;
}
public uint Parameter { get; }
public PerformanceStat Stat { get; }
}
2026-06-21 09:37:54 +01:00
private sealed class EquippedItemStatCacheEntry
{
public EquippedItemStatCacheEntry(PerformanceStat stat, StatConditionMask dependencyMask)
{
Stat = stat;
DependencyMask = dependencyMask;
Dirty = true;
}
public PerformanceStat Stat { get; set; }
public StatConditionMask DependencyMask { get; set; }
public float Value { get; set; }
public int InputStamp { get; set; }
public bool Dirty { get; set; }
}
private readonly struct EquippedItemBaseStatCacheEntry
{
public EquippedItemBaseStatCacheEntry(float value, int inputStamp)
{
Value = value;
InputStamp = inputStamp;
}
public float Value { get; }
public int InputStamp { get; }
}
}
public class EquippedCargoBay : EquippedItem
{
public readonly ReactiveDictionary<ItemInstance, int2> Cargo = new ReactiveDictionary<ItemInstance, int2>();
public readonly ItemInstance[,] Occupancy;
2026-06-14 23:09:49 +01:00
public readonly Dictionary<string, List<ItemInstance>> ItemsOfType = new Dictionary<string, List<ItemInstance>>();
2026-06-14 04:47:02 +01:00
public Shape InteriorShape { get; }
public float Mass { get; private set; }
public float ThermalMass { get; private set; }
public string Name { get; }
public Shape UnoccupiedSpace
{
get
{
2026-06-14 04:47:02 +01:00
var unoccupied = new Shape(InteriorShape.Width, InteriorShape.Height);
foreach (var v in unoccupied.AllCoordinates)
unoccupied[v] = Occupancy[v.x, v.y] == null;
return unoccupied;
}
}
2026-06-14 23:09:49 +01:00
public bool ContainsItem(string itemKey)
{
return !string.IsNullOrWhiteSpace(itemKey) && ItemsOfType.ContainsKey(itemKey);
}
public ItemInstance GetFirstItem(string itemKey)
{
return ContainsItem(itemKey) ? ItemsOfType[itemKey].FirstOrDefault() : null;
}
public EquippedCargoBay(ItemManager itemManager, EquippableItem item, int2 position, Entity entity, string name) : base(itemManager, item, position, entity)
{
2026-06-14 04:47:02 +01:00
var typedCargoBay = ItemManager.GetRuntimeItem(EquippableItem);
InteriorShape = ToShape(
typedCargoBay?.InteriorShapeWidth ?? 1,
typedCargoBay?.InteriorShapeHeight ?? 1,
typedCargoBay?.InteriorShapeCells);
Name = name;
2026-06-14 04:47:02 +01:00
Mass = ItemManager.GetMass(EquippableItem);
ThermalMass = ItemManager.GetThermalMass(EquippableItem);
2026-06-14 04:47:02 +01:00
Occupancy = new ItemInstance[InteriorShape.Width, InteriorShape.Height];
}
// Check whether the given item will fit when its origin is placed at the given coordinate
public bool ItemFits(ItemInstance item, int2 cargoCoord)
{
2026-06-14 04:47:02 +01:00
var itemShape = GetItemShape(item);
// Check every cell of the item's shape
2026-06-14 04:47:02 +01:00
foreach (var i in itemShape.Coordinates)
{
// If there is an item already occupying that space, it won't fit
2026-06-14 04:47:02 +01:00
var itemCargoCoord = cargoCoord + itemShape.Rotate(i, item.Rotation);
if (!InteriorShape[itemCargoCoord] || (Occupancy[itemCargoCoord.x, itemCargoCoord.y] != null && Occupancy[itemCargoCoord.x, itemCargoCoord.y] != item)) return false;
}
return true;
}
public bool TryFindSpace(ItemInstance item)
{
if (item is SimpleCommodity simpleCommodity)
return TryFindSpace(simpleCommodity, out _);
if (item is CraftedItemInstance craftedItem)
return TryFindSpace(craftedItem, out _);
return false;
}
// Tries to find a place to put the given items in the inventory
// Will attempt to fill existing item stacks first
// Returns true only when ALL of the items have places to go
public bool TryFindSpace(SimpleCommodity item, out List<int2> positions)
{
positions = new List<int2>();
2026-06-14 04:47:02 +01:00
var maxStack = GetMaxStack(item);
var remainingQuantity = item.Quantity;
// For simple commodities, search for existing item stacks to add to
foreach (var cargoItem in Cargo.Keys)
{
2026-06-14 23:09:49 +01:00
if (item.ItemKey != cargoItem.ItemKey) continue;
var cargoCommodity = (SimpleCommodity) cargoItem;
2026-06-14 04:47:02 +01:00
if (cargoCommodity.Quantity >= maxStack) continue;
// Subtract remaining space in existing stack from remaining quantity
2026-06-21 09:37:54 +01:00
remainingQuantity -= Math.Min(maxStack - cargoCommodity.Quantity, remainingQuantity);
positions.Add(Cargo[cargoItem]);
// If we've moved all of the items into existing stacks, no need to search for empty space!
if (remainingQuantity == 0) return true;
}
// TODO: Try alternate item rotations / use Shape.FitsWithin
// Search all the space in the cargo bay for an empty space where the item fits
2026-06-14 04:47:02 +01:00
foreach (var cargoCoord in InteriorShape.Coordinates)
{
if (ItemFits(item, cargoCoord))
{
positions.Add(cargoCoord);
return true;
}
}
return false;
}
// Searches the cargo bay for a position where the item will fit, returns true when found
public bool TryFindSpace(CraftedItemInstance item, out int2 position)
{
// Search all the space in the cargo bay for an empty space where the item fits
2026-06-14 04:47:02 +01:00
foreach (var cargoCoord in InteriorShape.Coordinates)
{
if (ItemFits(item, cargoCoord))
{
position = cargoCoord;
return true;
}
}
2026-06-21 09:37:54 +01:00
position = new int2(0, 0);
return false;
}
public bool TryStore(ItemInstance item)
{
if (item is SimpleCommodity simpleCommodity)
return TryStore(simpleCommodity);
if (item is CraftedItemInstance craftedItem)
return TryStore(craftedItem);
return false;
}
public bool TryStore(ItemInstance item, int2 cargoCoord)
{
if (item is SimpleCommodity simpleCommodity)
return TryStore(simpleCommodity, cargoCoord);
if (item is CraftedItemInstance craftedItem)
return TryStore(craftedItem, cargoCoord);
return false;
}
// Attempts to store all of the given item anywhere in the inventory
// Will attempt to fill existing item stacks first
// Returns true only when ALL of the items are successfully stored
public bool TryStore(SimpleCommodity item)
{
TryFindSpace(item, out var positions);
foreach (var position in positions)
{
if (TryStore(item, position)) return true;
}
return false;
}
// Try to store the given commodity at the given position
// If there's a stack at the given position it will be added to
// Returns true only when ALL of the items are successfully stored
public bool TryStore(SimpleCommodity item, int2 cargoCoord)
{
2026-06-14 04:47:02 +01:00
var itemShape = GetItemShape(item);
var maxStack = GetMaxStack(item);
if (ItemFits(item, cargoCoord))
{
2026-06-14 04:47:02 +01:00
foreach (var p in itemShape.Coordinates)
{
2026-06-14 04:47:02 +01:00
var pos = cargoCoord + itemShape.Rotate(p, item.Rotation);
Occupancy[pos.x, pos.y] = item;
}
Cargo[item] = cargoCoord;
2026-06-14 23:09:49 +01:00
AddTypedItemIndex(item);
}
2026-06-14 23:09:49 +01:00
else if (Occupancy[cargoCoord.x, cargoCoord.y] is SimpleCommodity cargoCommodity && cargoCommodity.ItemKey == item.ItemKey)
{
2026-06-14 04:47:02 +01:00
if (cargoCommodity.Quantity + item.Quantity <= maxStack)
{
cargoCommodity.Quantity += item.Quantity;
}
else
{
2026-06-14 04:47:02 +01:00
var quantityTransferred = maxStack - cargoCommodity.Quantity;
item.Quantity -= quantityTransferred;
2026-06-14 04:47:02 +01:00
cargoCommodity.Quantity = maxStack;
2026-06-14 04:47:02 +01:00
Mass += GetUnitMass(item) * quantityTransferred;
ThermalMass += GetUnitThermalMass(item) * quantityTransferred;
return false;
}
}
else return false;
Mass += ItemManager.GetMass(item);
ThermalMass += ItemManager.GetThermalMass(item);
return true;
}
// Try to store the given item anywhere it will fit, returns true when the item was successfully stored
public bool TryStore(CraftedItemInstance item) => TryFindSpace(item, out var position) && TryStore(item, position);
// Try to store the given item at the given position, returns true when the item was successfully stored
public bool TryStore(CraftedItemInstance item, int2 cargoCoord)
{
if (!ItemFits(item, cargoCoord)) return false;
2026-06-14 04:47:02 +01:00
var itemShape = GetItemShape(item);
foreach (var p in itemShape.Coordinates)
{
2026-06-14 04:47:02 +01:00
var pos = cargoCoord + itemShape.Rotate(p, item.Rotation);
Occupancy[pos.x, pos.y] = item;
}
Cargo[item] = cargoCoord;
2026-06-14 23:09:49 +01:00
AddTypedItemIndex(item);
Mass += ItemManager.GetMass(item);
ThermalMass += ItemManager.GetThermalMass(item);
return true;
}
public SimpleCommodity Remove(SimpleCommodity item, int quantity)
{
if (!Cargo.ContainsKey(item))
{
ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!");
return null;
}
if(quantity >= item.Quantity)
{
2026-06-14 04:47:02 +01:00
foreach(var v in InteriorShape.Coordinates)
if (Occupancy[v.x, v.y] == item)
Occupancy[v.x, v.y] = null;
Cargo.Remove(item);
2026-06-14 23:09:49 +01:00
RemoveTypedItemIndex(item);
Mass -= ItemManager.GetMass(item);
ThermalMass -= ItemManager.GetThermalMass(item);
return item;
}
item.Quantity -= quantity;
2026-06-14 04:47:02 +01:00
Mass -= GetUnitMass(item) * quantity;
ThermalMass -= GetUnitThermalMass(item) * quantity;
return new SimpleCommodity{Reference = item.Reference, Quantity = quantity, Rotation = item.Rotation};
}
public void Remove(CraftedItemInstance item)
{
if (!Cargo.ContainsKey(item))
{
ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!");
return;
}
2026-06-14 04:47:02 +01:00
foreach(var v in InteriorShape.Coordinates)
if (Occupancy[v.x, v.y] == item)
Occupancy[v.x, v.y] = null;
Cargo.Remove(item);
2026-06-14 23:09:49 +01:00
RemoveTypedItemIndex(item);
Mass -= ItemManager.GetMass(item);
ThermalMass -= ItemManager.GetThermalMass(item);
}
public void Remove(ItemInstance item)
{
if (item is SimpleCommodity simpleCommodity)
Remove(simpleCommodity, simpleCommodity.Quantity);
if (item is CraftedItemInstance craftedItem)
Remove(craftedItem);
}
2026-06-14 04:47:02 +01:00
private Shape GetItemShape(ItemInstance item)
{
var typedItem = ItemManager.GetRuntimeItem(item);
return ToShape(typedItem?.ShapeWidth ?? 1, typedItem?.ShapeHeight ?? 1, typedItem?.ShapeCells);
}
2026-06-14 23:09:49 +01:00
private void AddTypedItemIndex(ItemInstance item)
{
var itemKey = item?.ItemKey ?? "";
if (string.IsNullOrWhiteSpace(itemKey))
return;
if (!ItemsOfType.ContainsKey(itemKey))
ItemsOfType[itemKey] = new List<ItemInstance>();
ItemsOfType[itemKey].Add(item);
}
private void RemoveTypedItemIndex(ItemInstance item)
{
var itemKey = item?.ItemKey ?? "";
if (string.IsNullOrWhiteSpace(itemKey) || !ItemsOfType.ContainsKey(itemKey))
return;
ItemsOfType[itemKey].Remove(item);
if (!ItemsOfType[itemKey].Any())
ItemsOfType.Remove(itemKey);
}
2026-06-14 04:47:02 +01:00
private int GetMaxStack(SimpleCommodity item)
{
var typedItem = ItemManager.GetRuntimeItem(item);
return typedItem != null && typedItem.MaxStack > 0 ? typedItem.MaxStack : 1;
}
private float GetUnitMass(ItemInstance item)
{
var typedItem = ItemManager.GetRuntimeItem(item);
return typedItem != null ? (float)typedItem.Mass : 0f;
}
private float GetUnitThermalMass(ItemInstance item)
{
var typedItem = ItemManager.GetRuntimeItem(item);
return typedItem != null ? (float)(typedItem.Mass * typedItem.SpecificHeat) : 0f;
}
private static Shape ToShape(int width, int height, IReadOnlyList<AetheriaRuntimeShapeCell> cells)
{
var shape = new Shape(Math.Max(width, 1), Math.Max(height, 1));
if (cells == null) return shape;
foreach (var cell in cells)
2026-06-21 09:37:54 +01:00
shape.SetCell(cell.X, cell.Y, true);
2026-06-14 04:47:02 +01:00
return shape;
}
public bool TryTransferItem(EquippedCargoBay target, SimpleCommodity item, int quantity)
{
if (!Cargo.ContainsKey(item))
{
ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!");
return false;
}
var oldPos = Cargo[item];
var newItem = Remove(item, quantity);
if (target.TryStore(item)) return true;
// Failed to transfer full quantity, move the remaining items back to their old slot
TryStore(newItem, oldPos);
return false;
}
public bool TryTransferItem(EquippedCargoBay target, CraftedItemInstance item)
{
if (!Cargo.ContainsKey(item))
{
ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!");
return false;
}
if (!target.TryStore(item)) return false;
Remove(item);
return true;
}
}
public class EquippedDockingBay : EquippedCargoBay
{
public Ship DockedShip;
2026-06-14 05:39:15 +01:00
public int2 MaxSize { get; }
public EquippedDockingBay(ItemManager itemManager, EquippableItem item, int2 position, Entity entity, string name) : base(itemManager, item, position, entity, name)
{
2026-06-14 05:39:15 +01:00
var typedDockingBay = ItemManager.GetRuntimeItem(EquippableItem);
MaxSize = typedDockingBay != null
? new int2(typedDockingBay.DockingMaxSizeX, typedDockingBay.DockingMaxSizeY)
2026-06-21 09:37:54 +01:00
: new int2(0, 0);
}
}
public class BehaviorGroup
{
public Behavior[] Behaviors;
public T GetBehavior<T>() where T : Behavior
{
foreach (var b in Behaviors)
{
if (!(b is T s)) continue;
return s;
}
return null;
}
public T GetExposed<T>() where T : Behavior, IInteractiveBehavior
{
foreach (var b in Behaviors)
{
if (!(b is T s) || !((IInteractiveBehavior)b).Exposed) continue;
return s;
}
return null;
}
//public IAnalogBehavior Axis;
2026-06-13 17:38:05 +01:00
}