Command seam fixes: the player's switches, ping and stance flips are PilotCommand presses; triggers, switches and ping are internal

ActivatedBehavior replaces IActivatedBehavior so Activate/Deactivate can be internal under Unity's C# 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 04:33:20 +02:00
commit bc05417450
14 changed files with 70 additions and 66 deletions

View file

@ -75,7 +75,7 @@ public class ActionBarSlot : MonoBehaviour
this,
entity.Equipment[savedActionBarGearBinding.EquipmentIndex],
entity.Equipment[savedActionBarGearBinding.EquipmentIndex]
.Behaviors[savedActionBarGearBinding.BehaviorIndex] as IActivatedBehavior),
.Behaviors[savedActionBarGearBinding.BehaviorIndex] as ActivatedBehavior),
SavedActionBarWeaponGroupBinding savedActionBarWeaponGroupBinding =>
new ActionBarWeaponGroupBinding(entity, this, savedActionBarWeaponGroupBinding.Group),
_ => null
@ -136,11 +136,11 @@ public class ActionBarConsumableBinding : ActionBarBinding
public class ActionBarGearBinding : ActionBarBinding
{
public EquippedItem Item { get; }
public IActivatedBehavior Behavior { get; }
public ActivatedBehavior Behavior { get; }
public bool Active;
public ActionBarGearBinding(Entity entity, ActionBarSlot slot, EquippedItem item, IActivatedBehavior behavior) : base(entity, slot)
public ActionBarGearBinding(Entity entity, ActionBarSlot slot, EquippedItem item, ActivatedBehavior behavior) : base(entity, slot)
{
Item = item;
var data = Entity.ItemManager.GetData(item.EquippableItem);

View file

@ -396,39 +396,23 @@ public class ActionGameManager : MonoBehaviour
ActionBar.gameObject.SetActive(!_uiHidden);
};
Input.Player.OverrideShutdown.performed += context =>
{
CurrentEntity.OverrideShutdown = !CurrentEntity.OverrideShutdown;
};
Input.Player.Ping.performed += context =>
{
CurrentEntity.Sensor?.Ping();
};
Input.Player.ToggleHeatsinks.performed += context =>
{
CurrentEntity.HeatsinksEnabled = !CurrentEntity.HeatsinksEnabled;
// TODO: SFX: Success/Fail
};
Input.Player.ToggleShield.performed += context =>
{
if (CurrentEntity.Shield != null)
{
CurrentEntity.Shield.Item.Enabled.Value = !CurrentEntity.Shield.Item.Enabled.Value;
// TODO: SFX: Success/Fail
}
};
// The switches and the ping are presses on the entity's command, flipped at the next step.
Input.Player.OverrideShutdown.performed += context => CurrentEntity.Command.ToggleOverrideShutdown();
Input.Player.Ping.performed += context => CurrentEntity.Command.Ping();
// TODO: SFX: Success/Fail
Input.Player.ToggleHeatsinks.performed += context => CurrentEntity.Command.ToggleHeatsinks();
// TODO: SFX: Success/Fail
Input.Player.ToggleShield.performed += context => CurrentEntity.Command.ToggleShield();
// Flips the player's own stance on the current target between hostile and neutral. Going
// neutral mid-fight safes weapons (Weapon.StanceAllowsFire); an unmarked/undetected target
// reads as non-hostile here, so the first press declares hostile.
// reads as non-hostile, so the first press declares hostile. The flip reads the stance when applied, so two
// presses in a step cancel.
Input.Player.ToggleStance.performed += context =>
{
var target = CurrentEntity.Target.Value.Entity;
if (target == null) return;
CurrentEntity.Command.Iff(target, !CurrentEntity.IsHostileTo(target));
CurrentEntity.Command.ToggleStance(target);
};
#region Targeting
@ -483,7 +467,7 @@ public class ActionGameManager : MonoBehaviour
switch (dragAction)
{
case EquippedItemDragObject equippedItemDragAction:
var trigger = equippedItemDragAction.EquippedItem.GetBehavior<IActivatedBehavior>();
var trigger = equippedItemDragAction.EquippedItem.GetBehavior<ActivatedBehavior>();
if (trigger == null) return false;
slot.Binding = new ActionBarGearBinding(CurrentEntity, slot, equippedItemDragAction.EquippedItem, trigger);
return true;

View file

@ -87,10 +87,15 @@ public abstract class Behavior
}
}
public interface IActivatedBehavior
// A behaviour its pilot triggers. Only PilotApplier pulls and releases it, from the pilot's PilotCommand (Pull, Release),
// so Activate and Deactivate are internal to the simulation: no script outside it triggers one in place.
public abstract class ActivatedBehavior : Behavior
{
void Activate();
void Deactivate();
protected ActivatedBehavior(BehaviorData data, EquippedItem item) : base(data, item) { }
protected ActivatedBehavior(BehaviorData data, ConsumableItemEffect consumable) : base(data, consumable) { }
internal abstract void Activate();
internal abstract void Deactivate();
}
public interface IEventBehavior

View file

@ -167,7 +167,7 @@ public class ChargedWeapon : InstantWeapon
return base.Execute(dt);
}
public override void Activate()
internal override void Activate()
{
// CanFire also holds a weapon whose released charge has a burst pending: its cooldown starts at its first round,
// so a charge started before then would overlap it.
@ -180,7 +180,7 @@ public class ChargedWeapon : InstantWeapon
}
}
public override void Deactivate()
internal override void Deactivate()
{
if (_charging)
{

View file

@ -198,7 +198,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
return true;
}
public override void Activate()
internal override void Activate()
{
// A refused beam never starts (operator ruling 2026-09-30): OnStartFiring drives the beam's visuals and
// audio, so starting it only for Execute to stop it a tick later would flash a beam that fires nothing.
@ -209,7 +209,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
}
}
public override void Deactivate()
internal override void Deactivate()
{
if (_firing)
{

View file

@ -286,7 +286,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
return true;
}
public override void Activate()
internal override void Activate()
{
if(CanFire)
Trigger();

View file

@ -136,7 +136,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Utility -- everything else.
public int DefaultPowerTier => PowerTiers.Utility;
public void Ping()
internal void Ping()
{
// Whole-or-nothing against the ping's own input capacitor -- fires only at full charge (operator
// ruling: no fraction of a shot). A consumable-hosted instance (Item == null) has no PowerBus entry, so

View file

@ -20,11 +20,11 @@ public class SwitchData : BehaviorData
}
}
public class Switch : Behavior, IActivatedBehavior
public class Switch : ActivatedBehavior
{
private SwitchData _data;
public bool Activated { get; set; }
public bool Activated { get; private set; }
public Switch(SwitchData data, EquippedItem item) : base(data, item)
{
@ -40,12 +40,12 @@ public class Switch : Behavior, IActivatedBehavior
return Activated;
}
public void Activate()
internal override void Activate()
{
Activated = true;
}
public void Deactivate()
internal override void Deactivate()
{
Activated = false;
}

View file

@ -20,9 +20,9 @@ public class TriggerData : BehaviorData
}
}
public class Trigger : Behavior, IActivatedBehavior
public class Trigger : ActivatedBehavior
{
public bool _pulled;
private bool _pulled;
public Trigger(TriggerData data, EquippedItem item) : base(data, item) { }
public Trigger(TriggerData data, ConsumableItemEffect item) : base(data, item) { }
@ -38,12 +38,12 @@ public class Trigger : Behavior, IActivatedBehavior
return false;
}
public void Activate()
internal override void Activate()
{
_pulled = true;
}
public void Deactivate()
internal override void Deactivate()
{
}
}

View file

@ -100,7 +100,7 @@ public abstract class WeaponData : BehaviorData
public float RatedRange => Range.Max;
}
public abstract class Weapon : Behavior, IActivatedBehavior
public abstract class Weapon : ActivatedBehavior
{
private WeaponData _data;
@ -179,12 +179,12 @@ public abstract class Weapon : Behavior, IActivatedBehavior
return true;
}
public virtual void Activate()
internal override void Activate()
{
_firing = true;
}
public virtual void Deactivate()
internal override void Deactivate()
{
_firing = false;
}

View file

@ -173,7 +173,7 @@ public abstract class Entity
public bool HeatsinksEnabled
{
get => _heatsinksEnabled;
set
internal set
{
if (value == _heatsinksEnabled) return;
_heatsinksEnabled = value;
@ -186,7 +186,7 @@ public abstract class Entity
public EntitySettings Settings { get; }
public bool OverrideShutdown { get; set; }
public bool OverrideShutdown { get; internal set; }
// The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it.
public float TractorCommand { get; internal set; }
@ -2170,7 +2170,7 @@ public class EquippedItem : IStatContext
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);
internal ReactiveProperty<bool> Enabled { get; } = new ReactiveProperty<bool>(true);
public ReadOnlyReactiveProperty<bool> Active { get; }
public ItemManager ItemManager { get; }

View file

@ -53,27 +53,35 @@ public sealed class PilotCommand
internal readonly List<EquippedItem> Selections = new List<EquippedItem>();
internal readonly List<string> Hails = new List<string>();
internal readonly List<ConsumableItemData> Uses = new List<ConsumableItemData>();
internal readonly List<(IActivatedBehavior Behavior, bool Pull)> Triggers = new List<(IActivatedBehavior, bool)>();
internal readonly List<(Entity Other, bool? Hostile)> Iffs = new List<(Entity, bool?)>();
internal readonly List<(ActivatedBehavior Behavior, bool Pull)> Triggers = new List<(ActivatedBehavior, bool)>();
internal readonly List<(Entity Other, bool? Hostile, bool Toggle)> Iffs = new List<(Entity, bool?, bool)>();
internal TargetVerb TargetVerb;
internal int AimCycles;
internal int ShutdownOverrideToggles, HeatsinkToggles, ShieldToggles, Pings;
public void Designate(TargetRef target) => Designations.Add(target);
public void SelectItem(EquippedItem item) => Selections.Add(item);
public void Hail(string message) => Hails.Add(message);
public void Use(ConsumableItemData consumable) => Uses.Add(consumable);
public void Pull(IActivatedBehavior behavior) => Triggers.Add((behavior, true));
public void Release(IActivatedBehavior behavior) => Triggers.Add((behavior, false));
public void Pull(ActivatedBehavior behavior) => Triggers.Add((behavior, true));
public void Release(ActivatedBehavior behavior) => Triggers.Add((behavior, false));
// The last verb in a step wins.
public void Verb(TargetVerb verb) => TargetVerb = verb;
public void CycleAim() => AimCycles++;
public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile));
public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile, false));
// A press that flips the stance on `other` as it stands when applied, in order with Iff, so two in a step cancel.
public void ToggleStance(Entity other) => Iffs.Add((other, null, true));
// The entity's own switches, pressed: each press flips its switch when applied, so two in a step cancel.
public void ToggleOverrideShutdown() => ShutdownOverrideToggles++;
public void ToggleHeatsinks() => HeatsinkToggles++;
public void ToggleShield() => ShieldToggles++;
public void Ping() => Pings++;
// The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state).
public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null;
// Whether the last trigger this pilot commanded for `behavior` this step is a pull.
public bool Pulls(IActivatedBehavior behavior)
public bool Pulls(ActivatedBehavior behavior)
{
for (var i = Triggers.Count - 1; i >= 0; i--)
if (ReferenceEquals(Triggers[i].Behavior, behavior)) return Triggers[i].Pull;
@ -90,6 +98,7 @@ public sealed class PilotCommand
Iffs.Clear();
TargetVerb = TargetVerb.None;
AimCycles = 0;
ShutdownOverrideToggles = HeatsinkToggles = ShieldToggles = Pings = 0;
}
}
@ -112,7 +121,12 @@ public static class PilotApplier
}
foreach (var item in command.Selections) entity.TrySelectTargetItem(item);
for (var i = 0; i < command.AimCycles; i++) entity.CycleAim();
foreach (var (other, hostile) in command.Iffs) entity.SetIff(other, hostile);
foreach (var (other, hostile, toggle) in command.Iffs) entity.SetIff(other, toggle ? !entity.IsHostileTo(other) : hostile);
for (var i = 0; i < command.ShutdownOverrideToggles; i++) entity.OverrideShutdown = !entity.OverrideShutdown;
for (var i = 0; i < command.HeatsinkToggles; i++) entity.HeatsinksEnabled = !entity.HeatsinksEnabled;
if (entity.Shield != null)
for (var i = 0; i < command.ShieldToggles; i++) entity.Shield.Item.Enabled.Value = !entity.Shield.Item.Enabled.Value;
for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping();
foreach (var hail in command.Hails) entity.SetMessage(hail);
foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable);
if (command.Aim is float3 aim) entity.Aim = aim;

View file

@ -72,7 +72,7 @@ combat state cannot run without Unity.**
- Player-only inputs, all plain entity writes that an agent could make:
- `TractorPower` (`ActionGameManager.cs:1214-1216`);
- `OverrideShutdown`, `HeatsinksEnabled`, shield `Enabled`, `Sensor.Ping` (`:339-362`);
- gear `IActivatedBehavior` (`ActionBarSlot.cs:159-168`);
- gear `ActivatedBehavior` (`ActionBarSlot.cs:159-168`);
- consumables (`ActionBarSlot.cs:119-122`, `Entity.TryActivateConsumable`).
- Player-only lifecycle actions. These are the orchestration this map moves:
- interact, which means warp, dock or undock (`ActionGameManager.cs:298-320`);
@ -1002,7 +1002,7 @@ mean we want to delete all the weapon types we created".
| `move <x> <y>` / `look <x> <z>` / `tractor <0..1>` | pilot entity fields, as `ActionGameManager.cs:1189-1216` |
| `target nearest\|none\|<entity#>` | `Pilot.Target.Value` |
| `fire <group> on\|off` | `WeaponGroups[g].weapons` Activate/Deactivate, as `ActionBarSlot.cs:190-204` |
| `gear <equipment#> on\|off` | `IActivatedBehavior` Activate/Deactivate |
| `gear <equipment#> on\|off` | `ActivatedBehavior` Activate/Deactivate |
| `pilot manual\|minion\|patrol\|goto station <i>\|goto wormhole <i>` | `Run.PilotAgent` |
| `dock` / `undock` / `board <entity#>` / `warp` / `tow` | `Run.TryDock` / `Undock` / `Board` / `TryEnterWormhole` / `Tow` |
| `hit <entity#> <damage> [hardpoint <k>]` | `Entity.ApplyHit` (Cut 2) |

View file

@ -287,11 +287,12 @@ public sealed partial class RunStartTests
}
// A trigger that counts its edges: a semi-auto fires once per Activate.
private sealed class SeamTrigger : IActivatedBehavior
private sealed class SeamTrigger : ActivatedBehavior
{
public int Pulled, Released;
public void Activate() => Pulled++;
public void Deactivate() => Released++;
public SeamTrigger(EquippedItem item) : base(null, item) { }
internal override void Activate() => Pulled++;
internal override void Deactivate() => Released++;
}
// A press is one pull at the next step and the button held after it pulls nothing more, so a semi-auto fires once;
@ -300,7 +301,7 @@ public sealed partial class RunStartTests
public void PlayerTriggerIsAnEdge()
{
var (arena, east, _) = SeamPair(false);
var trigger = new SeamTrigger();
var trigger = new SeamTrigger(SeamGun(east).Item);
east.Command.Pull(trigger);
Assert.Equal(0, trigger.Pulled);
for (var step = 0; step < 10; step++) arena.Update(SeamDt);