Entity: an equipped item keeps the hardpoint it occupies

The placement rule (HardpointAt) already finds the one hardpoint an item lies
in at equip; TryEquip threw that answer away and eighteen readers recomputed it
from the item's origin cell, which need not lie in the hardpoint (Soul's
scenarios batch-4 P30). EquippedItem.Hardpoint now carries the answer, null for
Tool gear and the hull, and every origin-cell lookup reads it instead:
FireControl.ArcFor, the eleven weapon managers, EntityInstance, ShipInstance
and ActionGameManager's articulation grouping. Entity.Hardpoints[,] stays the
per-cell map for armour and display.

TryFindSpace's hardpoint search tries every origin the fit rule can accept,
including ones up and left of the hardpoint, so a design whose shape lacks its
origin cell is found wherever ItemFits would place it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 14:57:27 +02:00
commit 5ea59ac6b5
15 changed files with 45 additions and 33 deletions

View file

@ -1053,12 +1053,12 @@ public class ActionGameManager : MonoBehaviour
_articulationGroups = CurrentEntity.Equipment
.Where(item => item.Behaviors.Any(x => x.Data is WeaponData && !(x.Data is LauncherData)))
.GroupBy(item => ZoneRenderer.EntityInstances[CurrentEntity]
.GetBarrel(CurrentEntity.Hardpoints[item.Position.x, item.Position.y])
.GetBarrel(item.Hardpoint)
.GetComponentInParent<ArticulationPoint>()?.Group ?? -1)
.Select((group, index) => {
return (
group.Select(item => CurrentEntity.Hardpoints[item.Position.x, item.Position.y]).ToArray(),
group.Select(item => ZoneRenderer.EntityInstances[CurrentEntity].GetBarrel(CurrentEntity.Hardpoints[item.Position.x, item.Position.y])).ToArray(),
group.Select(item => item.Hardpoint).ToArray(),
group.Select(item => ZoneRenderer.EntityInstances[CurrentEntity].GetBarrel(item.Hardpoint)).ToArray(),
Crosshairs[index]
);
}).ToArray();

View file

@ -242,7 +242,7 @@ public class EntityInstance : MonoBehaviour
}
}
var hp = Entity.Hardpoints[item.Position.x, item.Position.y];
var hp = item.Hardpoint;
if (hp != null && item.Data.SoundBank != 0)
{
var hardpointTransform = EquipmentHardpoints.FirstOrDefault(x => x.name == hp.Transform);
@ -257,7 +257,7 @@ public class EntityInstance : MonoBehaviour
BarrelIndices = new Dictionary<HardpointData, int>();
foreach (var radiator in entity.GetBehaviors<Radiator>())
{
var hp = Entity.Hardpoints[radiator.Item.Position.x, radiator.Item.Position.y];
var hp = radiator.Item.Hardpoint;
if (hp != null && hp.Type == HardpointType.Radiator)
{
var mesh = RadiatorHardpoints.FirstOrDefault(x => x.name == hp.Transform);

View file

@ -67,7 +67,7 @@ public class ShipInstance : EntityInstance
var particles = Instantiate(EngineAssets.Load<ParticleSystem>(effectData.ParticlesPrefab), transform, false);
var particlesShape = particles.shape;
var thrusterHardpoint = ThrusterHardpoints
.FirstOrDefault(t => t.name == ship.Hardpoints[thruster.Item.Position.x, thruster.Item.Position.y].Transform);
.FirstOrDefault(t => t.name == thruster.Item.Hardpoint.Transform);
particlesShape.meshRenderer = thrusterHardpoint?.Emitter;
// if (!string.IsNullOrEmpty(thruster.Item.Data.SoundEffectTrigger) && thrusterHardpoint != null)
// {

View file

@ -11,7 +11,7 @@ public class ChargeEffectManager : MonoBehaviour
public void StartCharging(ChargedWeapon weapon, EquippedItem item, EntityInstance source)
{
var effect = EffectPrototype.Instantiate<WeaponChargeEffect>();
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
var t = effect.transform;
t.SetParent(barrel);

View file

@ -12,7 +12,7 @@ public class ConstantLaserManager : ConstantWeaponEffectManager
{
var p = LaserPrototype.Instantiate<ConstantLaser>();
p.SourceEntity = source.Entity;
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
p.Range = item.Evaluate(data.Range);
var t = p.transform;

View file

@ -13,7 +13,7 @@ public class ConstantLightningManager : ConstantWeaponEffectManager
{
var p = LightningPrototype.Instantiate<ConstantLightning>();
p.Source = source;
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
p.Barrel = barrel;
p.Range = item.Evaluate(data.Range);

View file

@ -13,7 +13,7 @@ public class ConstantParticleWeaponManager : ConstantWeaponEffectManager
var p = WeaponPrototype.Instantiate<ConstantParticleWeapon>();
p.Source = source;
p.Target = target;
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
var t = p.transform;
t.SetParent(barrel);

View file

@ -45,7 +45,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager
if (target != null) p.Target = target.transform;
else p.TargetPosition = () => burst;
p.Frequency = launcher.DodgeFrequency;
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
p.StartPosition = (p.transform.position = barrel.position).ToCultMath();
p.Range = weapon.Range;
@ -66,7 +66,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager
p.Source = source.transform;
p.SourceEntity = source.Entity;
p.Frequency = guidance.DodgeFrequency;
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
p.StartPosition = (p.transform.position = barrel.position).ToCultMath();
p.Range = weapon.Range;

View file

@ -7,7 +7,7 @@ public class HitscanManager : InstantWeaponEffectManager
{
var p = Prototype.Instantiate<HitscanEffect>();
p.SourceEntity = source.Entity;
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
var t = p.transform;
t.SetParent(barrel, false);

View file

@ -9,7 +9,7 @@ public class LaserManager : InstantWeaponEffectManager
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
{
var p = ProjectilePrototype.Instantiate<Laser>();
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
var t = p.transform;
t.SetParent(barrel);

View file

@ -9,7 +9,7 @@ public class LightningGunManager : InstantWeaponEffectManager
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
{
var p = Prototype.Instantiate<Lightning>();
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
p.ShotId = shotId;
p.Barrel = barrel;

View file

@ -13,7 +13,7 @@ public class MineManager : InstantWeaponEffectManager
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
{
var p = ProjectilePrototype.Instantiate<Mine>();
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
var angle = weapon.Spread / 2;
p.Source = source;

View file

@ -11,7 +11,7 @@ public class ProjectileManager : InstantWeaponEffectManager
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
{
var p = ProjectilePrototype.Instantiate<Projectile>();
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
var angle = weapon.Spread / 2;
p.ShotId = shotId;

View file

@ -601,7 +601,7 @@ public abstract class Entity
private void MapEntity()
{
var hullData = ItemManager.GetData(Hull) as HullData;
EquippedHull = new EquippedItem(ItemManager, Hull, int2.zero, this);
EquippedHull = new EquippedItem(ItemManager, Hull, int2.zero, this, null);
Equipment.Add(EquippedHull);
// A bare hull, one TryEquip never ran on, still updates: its ordered equipment is the hull alone.
_orderedEquipment = Equipment.ToArray();
@ -787,9 +787,11 @@ public abstract class Entity
return item.EquippableItem;
}
// Check whether the given item will fit when its origin is placed at the given coordinate
private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord)
// Check whether the given item will fit when its origin is placed at the given coordinate. For hardpoint gear,
// hardpoint is the one hardpoint the item would occupy there (HardpointAt); Tool gear occupies none.
private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord, out HardpointData hardpoint)
{
hardpoint = null;
// Items without specific hardpoints on the ship can be freely rotated and placed anywhere
if (itemData.HardpointType == HardpointType.Tool)
{
@ -806,7 +808,7 @@ public abstract class Entity
}
else
{
var hardpoint = HardpointAt(itemData, hullData, hullCoord);
hardpoint = HardpointAt(itemData, hullData, hullCoord);
if (hardpoint == null) return false;
item.Rotation = hardpoint.Rotation;
}
@ -842,7 +844,7 @@ public abstract class Entity
var itemData = ItemManager.GetData(item);
var hullData = ItemManager.GetData(Hull) as HullData;
return ItemFits(itemData, hullData, item, hullCoord);
return ItemFits(itemData, hullData, item, hullCoord, out _);
}
public bool TryFindSpace(EquippableItem item, out int2 hullCoord)
@ -862,7 +864,7 @@ public abstract class Entity
{
foreach (var hullCoord2 in hullData.InteriorCells.Coordinates)
{
if (ItemFits(itemData, hullData, item, hullCoord2))
if (ItemFits(itemData, hullData, item, hullCoord2, out _))
{
hullCoord = hullCoord2;
return true;
@ -874,16 +876,21 @@ public abstract class Entity
// Search the ship for an empty hardpoint that matches the type and shape of the item
else
{
// Every offset within a hardpoint of the item's type, the same offsets the fit rule tries
// Every origin the fit rule (HardpointData.TakesAt) can accept: the item, turned to the hardpoint's rotation,
// may sit as far up and left as its own extent allows, so a design whose shape lacks its origin cell is
// found wherever it fits, not only where its origin lies inside the hardpoint.
foreach (var hardpoint in hullData.Hardpoints)
{
if(hardpoint.Type == itemData.HardpointType)
{
for (var x = 0; x < hardpoint.Shape.Width; x++)
for (var y = 0; y < hardpoint.Shape.Height; y++)
var turned = hardpoint.Rotation == ItemRotation.Clockwise || hardpoint.Rotation == ItemRotation.CounterClockwise
? int2(itemData.Shape.Height, itemData.Shape.Width)
: int2(itemData.Shape.Width, itemData.Shape.Height);
for (var x = 1 - turned.x; x < hardpoint.Shape.Width; x++)
for (var y = 1 - turned.y; y < hardpoint.Shape.Height; y++)
{
var hullCoord2 = hardpoint.Position + int2(x, y);
if (ItemFits(itemData, hullData, item, hullCoord2))
if (ItemFits(itemData, hullData, item, hullCoord2, out _))
{
hullCoord = hullCoord2;
return true;
@ -909,7 +916,7 @@ public abstract class Entity
var itemData = ItemManager.GetData(item);
var hullData = ItemManager.GetData(Hull) as HullData;
if (!ItemFits(itemData, hullData, item, hullCoord)) return false;
if (!ItemFits(itemData, hullData, item, hullCoord, out var hardpoint)) return false;
EquippedItem equippedItem;
if (itemData.HardpointType == HardpointType.Tool)
@ -929,13 +936,13 @@ public abstract class Entity
}
else
{
equippedItem = new EquippedItem(ItemManager, item, hullCoord, this);
equippedItem = new EquippedItem(ItemManager, item, hullCoord, this, null);
Equipment.Add(equippedItem);
}
}
else
{
equippedItem = new EquippedItem(ItemManager, item, hullCoord, this);
equippedItem = new EquippedItem(ItemManager, item, hullCoord, this, hardpoint);
Equipment.Add(equippedItem);
}
@ -1358,6 +1365,10 @@ public class EquippedItem : IStatContext
public float Wear { get; private set; }
public Shape InsetShape { get; }
public Entity Entity { get; }
// The one hardpoint this item occupies, decided once at equip by the placement rule (Entity.HardpointAt);
// null for Tool gear and the hull. The item's origin cell need not lie in it: Entity.Hardpoints[,] is a
// per-cell map for armour and display, never an item lookup.
public HardpointData Hardpoint { get; }
public EquippableItemData Data { get; }
public Lot Lot { get; }
@ -1454,11 +1465,12 @@ public class EquippedItem : IStatContext
SetAudioParameter(metaObject.Id, v, true);
}
public EquippedItem(ItemManager itemManager, EquippableItem item, int2 position, Entity entity)
public EquippedItem(ItemManager itemManager, EquippableItem item, int2 position, Entity entity, HardpointData hardpoint)
{
ItemManager = itemManager;
Data = ItemManager.GetData(item);
Entity = entity;
Hardpoint = hardpoint;
EquippableItem = item;
Lot = ItemManager.GetLot(item);
Position = position;
@ -1707,7 +1719,7 @@ public class EquippedCargoBay : EquippedItem
}
}
public EquippedCargoBay(ItemManager itemManager, EquippableItem item, int2 position, Entity entity, string name) : base(itemManager, item, position, entity)
public EquippedCargoBay(ItemManager itemManager, EquippableItem item, int2 position, Entity entity, string name) : base(itemManager, item, position, entity, null)
{
Data = ItemManager.GetData(EquippableItem) as CargoBayData;
Name = name;

View file

@ -35,7 +35,7 @@ public static class FireControl
// GameplaySettings.FiringArc. Zero on the hardpoint means "use the default," not "no arc."
public static float ArcFor(EquippedItem item)
{
var hardpoint = item.Entity.Hardpoints[item.Position.x, item.Position.y];
var hardpoint = item.Hardpoint;
var arc = hardpoint?.FiringArc ?? 0f;
return arc > 0 ? arc : item.ItemManager.GameplaySettings.FiringArc;
}