Command seam, player: PilotCommand gains Tractor, the target Verb, CycleAim and Iff; HelmInput.Demand returns a TurnCommand resolved each step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9d9a8ed031
commit
76143e50d1
4 changed files with 209 additions and 9 deletions
|
|
@ -13,6 +13,7 @@ public class HelmInput
|
|||
|
||||
public void Toggle() => Latched = !Latched;
|
||||
|
||||
public float Demand(Ship ship, float2 aim, float turnAxis) =>
|
||||
FaceAim ? Steering.Toward(ship, aim) : clamp(turnAxis, -1f, 1f);
|
||||
// The turn the player commands: the aim heading, resolved against the hull at each sim step, or the turn axis.
|
||||
public TurnCommand Demand(float2 aim, float turnAxis) =>
|
||||
FaceAim ? TurnCommand.Toward(aim) : TurnCommand.OfRate(clamp(turnAxis, -1f, 1f));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,16 @@ public readonly struct TurnCommand
|
|||
public float Resolve(Ship ship) => _toward ? Steering.Toward(ship, _heading) : _rate;
|
||||
}
|
||||
|
||||
// The player's target presses, each answered by the entity's own chooser (Entity.TargetNext and the rest) when applied.
|
||||
public enum TargetVerb
|
||||
{
|
||||
None,
|
||||
UnderReticle,
|
||||
NearestEnemy,
|
||||
Next,
|
||||
Previous
|
||||
}
|
||||
|
||||
// The one input an entity's pilot gives it (its agent, the player, a scripted pilot): only own-ship vectors and handles the
|
||||
// pilot was given (ruling observation-sensors-only). Commands are per entity, never per Flight or side: a side may be one
|
||||
// ship (ruling brain-one-per-side-r2). PilotApplier.Apply is the only writer of the entity's fields from it; the pilot
|
||||
|
|
@ -37,12 +47,16 @@ public sealed class PilotCommand
|
|||
public TurnCommand? Turn;
|
||||
public float2? Movement;
|
||||
public float3? Aim;
|
||||
public float? Tractor;
|
||||
|
||||
internal readonly List<TargetRef> Designations = new List<TargetRef>();
|
||||
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 TargetVerb TargetVerb;
|
||||
internal int AimCycles;
|
||||
|
||||
public void Designate(TargetRef target) => Designations.Add(target);
|
||||
public void SelectItem(EquippedItem item) => Selections.Add(item);
|
||||
|
|
@ -50,6 +64,10 @@ public sealed class PilotCommand
|
|||
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));
|
||||
// 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));
|
||||
|
||||
// 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;
|
||||
|
|
@ -69,12 +87,15 @@ public sealed class PilotCommand
|
|||
Hails.Clear();
|
||||
Uses.Clear();
|
||||
Triggers.Clear();
|
||||
Iffs.Clear();
|
||||
TargetVerb = TargetVerb.None;
|
||||
AimCycles = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The one writer of an entity's pilot-controlled fields, called only from Zone.Update. The order is a rule: the target
|
||||
// lands before its item is selected, and the aim before the triggers, so a pilot fires along the aim it commands this
|
||||
// step, as the player does.
|
||||
// lands (a designation, then a chooser verb) before its item is selected or the aim cycled, and the aim before the
|
||||
// triggers, so a pilot fires along the aim it commands this step.
|
||||
public static class PilotApplier
|
||||
{
|
||||
public static void Apply(Entity entity)
|
||||
|
|
@ -82,7 +103,16 @@ public static class PilotApplier
|
|||
var command = entity.Command;
|
||||
foreach (var target in command.Designations)
|
||||
if (!entity.Target.Value.Equals(target)) entity.SetTarget(target);
|
||||
switch (command.TargetVerb)
|
||||
{
|
||||
case TargetVerb.UnderReticle: entity.TargetUnderReticle(); break;
|
||||
case TargetVerb.NearestEnemy: entity.TargetNearestEnemy(); break;
|
||||
case TargetVerb.Next: entity.TargetNext(); break;
|
||||
case TargetVerb.Previous: entity.TargetPrevious(); break;
|
||||
}
|
||||
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 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;
|
||||
|
|
@ -94,6 +124,7 @@ public static class PilotApplier
|
|||
if (command.Turn is TurnCommand turn) ship.Turn = turn.Resolve(ship);
|
||||
if (command.Movement is float2 movement) ship.MovementDirection = movement;
|
||||
}
|
||||
if (command.Tractor is float tractor) entity.TractorCommand = tractor;
|
||||
command.ClearOneShots();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
168
tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs
Normal file
168
tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/* 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.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using static CultMath.math;
|
||||
using float2 = CultMath.float2;
|
||||
using float3 = CultMath.float3;
|
||||
|
||||
// The command seam, player half (aetheria-arena, cut command-seam-player): the player's input writes only its entity's
|
||||
// PilotCommand, as the agents do, and PilotApplier lands it at the next zone step. Its target presses are verbs the entity's
|
||||
// chooser answers, its stance and aim-cycle presses are one-shots, its helm and tractor are held, and its triggers are
|
||||
// edges: a press is one pull and a let-go one release.
|
||||
public sealed partial class RunStartTests
|
||||
{
|
||||
// East, west 200 away and a third 250 away, all seen by east once the zone has stepped.
|
||||
private (Zone arena, Ship east, Ship west, Ship third) PlayerSeamTrio()
|
||||
{
|
||||
Ship third = null;
|
||||
var (arena, east, west) = SeamPair(false, (stage, e, w) =>
|
||||
third = stage.Place(EvLonginus(stage), float2(350, 0), facing: float2(-1, 0)) as Ship);
|
||||
for (var step = 0; step < 60; step++) arena.Update(SeamDt);
|
||||
Assert.Contains(west, east.VisibleEntities);
|
||||
Assert.Contains(third, east.VisibleEntities);
|
||||
return (arena, east, west, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelmDemandIsAnIntent()
|
||||
{
|
||||
var (arena, east, _) = SeamPair(false);
|
||||
var held = new HelmInput { Held = true };
|
||||
var heading = float2(0, 1); // a quarter turn from its facing
|
||||
Assert.Equal(Steering.Toward(east, heading), held.Demand(heading, .5f).Resolve(east));
|
||||
Assert.Equal(.5f, new HelmInput().Demand(heading, .5f).Resolve(east));
|
||||
Assert.Equal(-1f, new HelmInput().Demand(heading, -4f).Resolve(east));
|
||||
|
||||
// One face-aim demand, held: it re-resolves against the hull at every step as the hull comes round.
|
||||
east.Command.Turn = held.Demand(heading, 0);
|
||||
var turns = new List<float>();
|
||||
for (var step = 0; step < 600; step++)
|
||||
{
|
||||
var expected = Steering.Toward(east, heading);
|
||||
arena.Update(SeamDt);
|
||||
Assert.Equal(expected, east.Turn);
|
||||
turns.Add(east.Turn);
|
||||
}
|
||||
Assert.True(turns.Distinct().Count() > 2, "the resolved turn changes as the hull turns");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetVerbsApplyOnceAtTheStep()
|
||||
{
|
||||
var (arena, east, west, third) = PlayerSeamTrio();
|
||||
Assert.True(east.Target.Value.IsNone);
|
||||
|
||||
// The last verb in a step wins: Previous would pick the farthest (third), Next picks the nearest.
|
||||
east.Command.Verb(TargetVerb.Previous);
|
||||
east.Command.Verb(TargetVerb.Next);
|
||||
Assert.True(east.Target.Value.IsNone, "nothing lands before the step");
|
||||
arena.Update(SeamDt);
|
||||
Assert.Same(west, east.Target.Value.Entity);
|
||||
arena.Update(SeamDt);
|
||||
Assert.Same(west, east.Target.Value.Entity);
|
||||
|
||||
east.Command.Verb(TargetVerb.Next);
|
||||
arena.Update(SeamDt);
|
||||
Assert.Same(third, east.Target.Value.Entity);
|
||||
|
||||
east.Command.Verb(TargetVerb.Previous);
|
||||
arena.Update(SeamDt);
|
||||
Assert.Same(west, east.Target.Value.Entity);
|
||||
|
||||
// Enemies only: the farther hostile, not the nearer neutral.
|
||||
east.SetIff(third, true);
|
||||
east.Command.Verb(TargetVerb.NearestEnemy);
|
||||
arena.Update(SeamDt);
|
||||
Assert.Same(third, east.Target.Value.Entity);
|
||||
|
||||
// The reticle picks along the aim; picking the held target again would clear it.
|
||||
east.Command.Aim = float3(-1, 0, 0);
|
||||
east.Command.Verb(TargetVerb.UnderReticle);
|
||||
arena.Update(SeamDt);
|
||||
Assert.Same(west, east.Target.Value.Entity);
|
||||
arena.Update(SeamDt);
|
||||
Assert.Same(west, east.Target.Value.Entity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IffAndCycleAimAreOneShots()
|
||||
{
|
||||
var (arena, east, west, _) = PlayerSeamTrio();
|
||||
Assert.False(east.IsHostileTo(west));
|
||||
east.Command.Iff(west, true);
|
||||
Assert.False(east.IsHostileTo(west), "nothing lands before the step");
|
||||
arena.Update(SeamDt);
|
||||
Assert.True(east.IsHostileTo(west));
|
||||
// Not replayed: a stance set after the step stays.
|
||||
east.SetIff(west, false);
|
||||
arena.Update(SeamDt);
|
||||
Assert.False(east.IsHostileTo(west));
|
||||
east.Command.Iff(west, true);
|
||||
east.Command.Iff(west, null);
|
||||
arena.Update(SeamDt);
|
||||
Assert.False(east.IsHostileTo(west), "the later stance in a step lands last");
|
||||
|
||||
var first = SeamGun(west).Item;
|
||||
var second = west.Weapons.Select(w => w.Item).First(i => i != first && i.Data.Name == first.Data.Name);
|
||||
east.RecordReveal(west, first, SignatureChannel.Emission, west.ClaimOf(first));
|
||||
east.RecordReveal(west, second, SignatureChannel.Emission, west.ClaimOf(second));
|
||||
east.Command.Verb(TargetVerb.Next);
|
||||
arena.Update(SeamDt);
|
||||
Assert.Same(west, east.Target.Value.Entity);
|
||||
var before = east.ResolvedAim;
|
||||
|
||||
east.Command.CycleAim();
|
||||
Assert.Equal(before.Item, east.ResolvedAim.Item);
|
||||
arena.Update(SeamDt);
|
||||
var once = east.ResolvedAim;
|
||||
Assert.True(once.Item != before.Item || once.Mount != before.Mount, "the cycle advanced the aim");
|
||||
arena.Update(SeamDt);
|
||||
Assert.Equal(once.Item, east.ResolvedAim.Item);
|
||||
Assert.Equal(once.Mount, east.ResolvedAim.Mount);
|
||||
}
|
||||
|
||||
// A trigger that counts its edges: a semi-auto fires once per Activate.
|
||||
private sealed class SeamTrigger : IActivatedBehavior
|
||||
{
|
||||
public int Pulled, Released;
|
||||
public void Activate() => Pulled++;
|
||||
public 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;
|
||||
// the let-go is one release.
|
||||
[Fact]
|
||||
public void PlayerTriggerIsAnEdge()
|
||||
{
|
||||
var (arena, east, _) = SeamPair(false);
|
||||
var trigger = new SeamTrigger();
|
||||
east.Command.Pull(trigger);
|
||||
Assert.Equal(0, trigger.Pulled);
|
||||
for (var step = 0; step < 10; step++) arena.Update(SeamDt);
|
||||
Assert.Equal((1, 0), (trigger.Pulled, trigger.Released));
|
||||
|
||||
east.Command.Release(trigger);
|
||||
for (var step = 0; step < 10; step++) arena.Update(SeamDt);
|
||||
Assert.Equal((1, 1), (trigger.Pulled, trigger.Released));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TractorIsHeld()
|
||||
{
|
||||
var (arena, east, _) = SeamPair(false);
|
||||
east.Command.Tractor = 1;
|
||||
for (var step = 0; step < 5; step++)
|
||||
{
|
||||
east.TractorCommand = 0; // a disturbance between steps: the held command lands again at every step
|
||||
arena.Update(SeamDt);
|
||||
Assert.Equal(1f, east.TractorCommand);
|
||||
}
|
||||
east.Command.Tractor = .25f;
|
||||
arena.Update(SeamDt);
|
||||
Assert.Equal(.25f, east.TractorCommand);
|
||||
}
|
||||
}
|
||||
|
|
@ -88,8 +88,8 @@ public sealed class SteeringTests
|
|||
{
|
||||
ship.Direction = direction;
|
||||
var expected = Steering.Toward(ship, aim);
|
||||
Assert.Equal(expected, held.Demand(ship, aim, axis));
|
||||
Assert.Equal(expected, latched.Demand(ship, aim, axis));
|
||||
Assert.Equal(expected, held.Demand(aim, axis).Resolve(ship));
|
||||
Assert.Equal(expected, latched.Demand(aim, axis).Resolve(ship));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -102,9 +102,9 @@ public sealed class SteeringTests
|
|||
|
||||
foreach (var aim in new[] { float2(0, 1), float2(1, 0), float2(-1, -1) })
|
||||
{
|
||||
Assert.Equal(.25f, helm.Demand(ship, aim, .25f));
|
||||
Assert.Equal(1f, helm.Demand(ship, aim, 3f));
|
||||
Assert.Equal(-1f, helm.Demand(ship, aim, -3f));
|
||||
Assert.Equal(.25f, helm.Demand(aim, .25f).Resolve(ship));
|
||||
Assert.Equal(1f, helm.Demand(aim, 3f).Resolve(ship));
|
||||
Assert.Equal(-1f, helm.Demand(aim, -3f).Resolve(ship));
|
||||
}
|
||||
|
||||
helm.Toggle();
|
||||
|
|
|
|||
Loading…
Reference in a new issue