using System; using Sandbox.Mapping; using Sandbox.Network; namespace SceneTests.Components; /// /// Shared helpers for the gameplay component tests. /// internal static class GameComponentTestUtils { /// /// Pushes a Time scope pinned to the scene's own clock. TimeSince fields captured /// outside of a GameTick (component construction, calls like Door.Open) record the /// ambient global Time.Now, which has no relation to the scene clock that drives /// OnFixedUpdate - this scope makes those captures line up with the scene clock so /// timed state machines behave deterministically in tests. /// public static IDisposable PushSceneClock( Scene scene ) { return Time.Scope( scene.TimeNow, 0.02 ); } /// /// Ticks the scene until the condition holds, giving up after maxTicks. The caller /// asserts the condition afterwards so a failure reports properly. /// public static void TickUntil( Scene scene, Func condition, int maxTicks ) { for ( int i = 0; i < maxTicks && !condition(); i++ ) { scene.GameTick(); } } /// /// Serializes a GameObject and deserializes it into a fresh disabled GameObject in /// the same scene, re-iding the copy so both can coexist. /// public static GameObject RoundTrip( GameObject go ) { var node = go.Serialize(); SceneUtility.MakeIdGuidsUnique( node ); var copy = new GameObject( false ); copy.Deserialize( node ); return copy; } } /// /// Pins the Prop component contract: procedural renderer/collider/rigidbody creation /// from the model, the static and ragdoll paths, health and damage handling through /// IDamageable, break callbacks, and serialization of the configured values. /// [TestClass] public class PropDamageTest { static Model ArrowModel => Model.Load( "models/arrow.vmdl" ); static Model CitizenModel => Model.Load( "models/citizen/citizen.vmdl" ); /// /// A model with a single physics part builds a renderer, a non-static /// ModelCollider and a Rigidbody. A model without prop data leaves Health at zero. /// [TestMethod] public void SingleBodyModelBuildsRendererAndRigidbody() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var prop = go.Components.Create(); prop.Model = ArrowModel; var renderer = go.Components.Get(); Assert.IsNotNull( renderer, "a renderer should be created from the model" ); Assert.AreEqual( ArrowModel, renderer.Model ); var collider = go.Components.Get(); Assert.IsNotNull( collider, "a model collider should be created" ); Assert.IsFalse( collider.Static ); var rb = go.Components.Get(); Assert.IsNotNull( rb, "a rigidbody should be created for a dynamic prop" ); Assert.IsTrue( rb.PhysicsBody.IsValid() ); Assert.AreEqual( 0f, prop.Health, "a model without prop data shouldn't set health" ); } /// /// A static prop builds only a static collider - no rigidbody is created. /// [TestMethod] public void StaticPropBuildsStaticColliderOnly() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var prop = go.Components.Create( false ); prop.IsStatic = true; prop.Model = ArrowModel; prop.Enabled = true; Assert.IsNotNull( go.Components.Get() ); var collider = go.Components.Get(); Assert.IsNotNull( collider ); Assert.IsTrue( collider.Static, "a static prop's collider should be static" ); Assert.IsNull( go.Components.Get(), "a static prop must not create a rigidbody" ); } /// /// A model with multiple physics parts (a ragdoll) builds a SkinnedModelRenderer /// and a ModelPhysics instead of the single collider/rigidbody pair. /// [TestMethod] public void RagdollModelBuildsModelPhysics() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var prop = go.Components.Create(); prop.Model = CitizenModel; Assert.IsNotNull( go.Components.Get(), "a skinned model needs a skinned renderer" ); var physics = go.Components.Get(); Assert.IsNotNull( physics, "a multi-part model should create ModelPhysics" ); Assert.AreEqual( CitizenModel, physics.Model ); Assert.IsTrue( physics.Bodies.Count > 0 ); } /// /// Setting Tint on the prop flows through to the procedural renderer. /// [TestMethod] public void TintFlowsToRenderer() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var prop = go.Components.Create(); prop.Model = ArrowModel; prop.Tint = Color.Red; Assert.AreEqual( Color.Red, go.Components.Get().Tint ); } /// /// Non-lethal damage reduces Health, fires the take-damage callback and records /// the attacker, without breaking the prop. /// [TestMethod] public void DamageReducesHealthAndTracksAttacker() { var scene = new Scene(); using var sceneScope = scene.Push(); var attacker = scene.CreateObject(); var go = scene.CreateObject(); var prop = go.Components.Create(); prop.Model = ArrowModel; prop.Health = 100f; int damaged = 0; int broken = 0; prop.OnPropTakeDamage = _ => damaged++; prop.OnPropBreak = () => broken++; prop.OnDamage( new DamageInfo { Damage = 30f, Attacker = attacker } ); Assert.AreEqual( 70f, prop.Health, 0.01f ); Assert.AreEqual( 1, damaged ); Assert.AreEqual( 0, broken ); Assert.AreEqual( attacker, prop.LastAttacker ); scene.ProcessDeletes(); Assert.IsTrue( go.IsValid(), "a surviving prop must not be destroyed" ); } /// /// A prop at zero health ignores further damage - no callback fires and health /// stays at zero - but the attacker is still recorded before the health check. /// [TestMethod] public void DamageIgnoredAtZeroHealth() { var scene = new Scene(); using var sceneScope = scene.Push(); var attacker = scene.CreateObject(); var go = scene.CreateObject(); var prop = go.Components.Create(); prop.Model = ArrowModel; int damaged = 0; prop.OnPropTakeDamage = _ => damaged++; Assert.AreEqual( 0f, prop.Health ); prop.OnDamage( new DamageInfo { Damage = 50f, Attacker = attacker } ); Assert.AreEqual( 0f, prop.Health ); Assert.AreEqual( 0, damaged, "the dead feel nothing" ); Assert.AreEqual( attacker, prop.LastAttacker, "the attacker is recorded even on a dead prop" ); scene.ProcessDeletes(); Assert.IsTrue( go.IsValid() ); } /// /// Damage that takes health to zero kills the prop: the break callback runs, /// health clamps to zero and the GameObject is destroyed. /// [TestMethod] public void LethalDamageBreaksAndDestroys() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var prop = go.Components.Create(); prop.Model = ArrowModel; prop.Health = 10f; int damaged = 0; int broken = 0; prop.OnPropTakeDamage = _ => damaged++; prop.OnPropBreak = () => broken++; prop.OnDamage( new DamageInfo { Damage = 25f } ); Assert.AreEqual( 0f, prop.Health, "health clamps to zero on death" ); Assert.AreEqual( 1, damaged, "the take damage callback fires before the kill" ); Assert.AreEqual( 1, broken ); scene.ProcessDeletes(); Assert.IsFalse( go.IsValid(), "a killed prop destroys its GameObject" ); } /// /// A prop's configured values survive a serialize/deserialize round trip. /// [TestMethod] public void SerializedPropKeepsConfiguredValues() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var prop = go.Components.Create(); prop.Model = ArrowModel; prop.Health = 42f; prop.Tint = Color.Red; prop.StartAsleep = true; var copy = GameComponentTestUtils.RoundTrip( go ); var prop2 = copy.Components.Get( true ); Assert.IsNotNull( prop2 ); Assert.AreEqual( ArrowModel, prop2.Model ); Assert.AreEqual( 42f, prop2.Health, 0.01f ); Assert.AreEqual( Color.Red, prop2.Tint ); Assert.IsTrue( prop2.StartAsleep ); } } /// /// Pins the damage-dealing components: TriggerHurt periodic damage with tag filters, /// RadiusDamage distance falloff and physics push, and FireDamage's fixed-interval /// burn against the root object. /// [TestClass] public class TriggerDamageTest { Connection _previousLocalConnection; NetworkSystem _previousNetworkSystem; /// /// Pins Connection.Local to a host connection and clears Networking.System so the /// host-gated damage paths (TriggerHurt's Networking.IsHost check) run locally /// instead of being silently skipped when another test in the assembly has leaked /// a non-host client connection. Same idiom as ChairTests.cs. /// [TestInitialize] public void PinHostNetworkingState() { _previousLocalConnection = Connection.Local; _previousNetworkSystem = Networking.System; Connection.Local = new TestConnection( Guid.NewGuid(), isHost: true ); Networking.System = null; } /// /// Restores whatever global networking state existed before the test, leaving the /// rest of the assembly exactly as it was. /// [TestCleanup] public void RestoreNetworkingState() { Connection.Local = _previousLocalConnection; Networking.System = _previousNetworkSystem; } /// /// Counts damage delivered through IDamageable, recording the per-event amount /// because RadiusDamage restores the DamageInfo's Damage after the loop. /// public sealed class DamageCounter : Component, Component.IDamageable { public int Events { get; private set; } public float TotalDamage { get; private set; } public float LastAmount { get; private set; } public DamageInfo Last { get; private set; } public void OnDamage( in DamageInfo damage ) { Events++; TotalDamage += damage.Damage; LastAmount = damage.Damage; Last = damage; } } /// /// Creates a non-falling rigidbody box with a damage counter at the position. /// static (GameObject go, DamageCounter counter) CreateVictim( Scene scene, Vector3 position, params string[] tags ) { var go = scene.CreateObject(); go.WorldPosition = position; foreach ( var tag in tags ) { go.Tags.Add( tag ); } var rb = go.Components.Create(); rb.Gravity = false; var box = go.Components.Create(); box.Scale = new Vector3( 10 ); var counter = go.Components.Create(); return (go, counter); } /// /// A body resting inside a TriggerHurt takes damage periodically - more than once /// over time, but rate limited well below once per fixed update - and the damage /// info carries the attacker, amount and configured tags. /// [TestMethod] public void TriggerHurtAppliesPeriodicDamage() { var scene = new Scene(); using var sceneScope = scene.Push(); var triggerGo = scene.CreateObject(); var triggerBox = triggerGo.Components.Create(); triggerBox.Scale = new Vector3( 100 ); triggerBox.IsTrigger = true; TriggerHurt hurt; using ( GameComponentTestUtils.PushSceneClock( scene ) ) { hurt = triggerGo.Components.Create(); } hurt.Damage = 5f; hurt.Rate = 0.5f; hurt.DamageTags.Add( "burny" ); var (_, counter) = CreateVictim( scene, Vector3.Zero ); for ( int i = 0; i < 35; i++ ) scene.GameTick(); Assert.IsTrue( counter.Events >= 2, $"damage should repeat over time: {counter.Events}" ); Assert.IsTrue( counter.Events <= 9, $"damage must be rate limited: {counter.Events}" ); Assert.AreEqual( 5f, counter.LastAmount, 0.01f ); Assert.AreEqual( triggerGo, counter.Last.Attacker ); Assert.IsTrue( counter.Last.Tags.Has( "burny" ) ); } /// /// With Include tags set, only targets carrying one of those tags take damage. /// [TestMethod] public void TriggerHurtIncludeFilter() { var scene = new Scene(); using var sceneScope = scene.Push(); var triggerGo = scene.CreateObject(); var triggerBox = triggerGo.Components.Create(); triggerBox.Scale = new Vector3( 100 ); triggerBox.IsTrigger = true; TriggerHurt hurt; using ( GameComponentTestUtils.PushSceneClock( scene ) ) { hurt = triggerGo.Components.Create(); } hurt.Rate = 0.1f; hurt.Include.Add( "food" ); var (_, tagged) = CreateVictim( scene, new Vector3( 20, 0, 0 ), "food" ); var (_, untagged) = CreateVictim( scene, new Vector3( -20, 0, 0 ) ); for ( int i = 0; i < 20; i++ ) scene.GameTick(); Assert.IsTrue( tagged.Events > 0, "the included target should be damaged" ); Assert.AreEqual( 0, untagged.Events, "targets without an include tag are skipped" ); } /// /// With Exclude tags set, targets carrying one of those tags are spared. /// [TestMethod] public void TriggerHurtExcludeFilter() { var scene = new Scene(); using var sceneScope = scene.Push(); var triggerGo = scene.CreateObject(); var triggerBox = triggerGo.Components.Create(); triggerBox.Scale = new Vector3( 100 ); triggerBox.IsTrigger = true; TriggerHurt hurt; using ( GameComponentTestUtils.PushSceneClock( scene ) ) { hurt = triggerGo.Components.Create(); } hurt.Rate = 0.1f; hurt.Exclude.Add( "god" ); var (_, godly) = CreateVictim( scene, new Vector3( 20, 0, 0 ), "god" ); var (_, mortal) = CreateVictim( scene, new Vector3( -20, 0, 0 ) ); for ( int i = 0; i < 20; i++ ) scene.GameTick(); Assert.AreEqual( 0, godly.Events, "excluded targets are spared" ); Assert.IsTrue( mortal.Events > 0, "everyone else gets hurt" ); } /// /// RadiusDamage applies on enable by default and falls off linearly with distance. /// The shared DamageInfo's Damage is restored to the full amount after the loop - /// only the value passed at call time carries the falloff. /// [TestMethod] public void RadiusDamageFalloffByDistance() { var scene = new Scene(); using var sceneScope = scene.Push(); var attacker = scene.CreateObject(); var (_, near) = CreateVictim( scene, new Vector3( 10, 0, 0 ) ); var (_, far) = CreateVictim( scene, new Vector3( 100, 0, 0 ) ); var rdGo = scene.CreateObject(); var rd = rdGo.Components.Create( false ); rd.Radius = 200f; rd.DamageAmount = 100f; rd.Occlusion = false; rd.Attacker = attacker; rd.DamageTags.Add( "explosion" ); rd.Enabled = true; Assert.AreEqual( 1, near.Events ); Assert.AreEqual( 1, far.Events ); Assert.AreEqual( 95f, near.LastAmount, 0.5f, "a target near the center takes almost full damage" ); Assert.AreEqual( 50f, far.LastAmount, 0.5f, "a target at half the radius takes half damage" ); Assert.AreEqual( attacker, far.Last.Attacker ); Assert.AreEqual( rdGo, far.Last.Weapon ); Assert.IsTrue( far.Last.Origin.AlmostEqual( Vector3.Zero ) ); Assert.IsTrue( far.Last.Tags.Has( "explosion" ) ); // The DamageInfo instance is shared - its Damage is restored to the full // amount after all targets have been damaged. Assert.AreEqual( 100f, far.Last.Damage, 0.5f ); } /// /// RadiusDamage pushes dynamic rigidbodies away from the center. /// [TestMethod] public void RadiusDamagePushesRigidbodies() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); go.WorldPosition = new Vector3( 50, 0, 0 ); var rb = go.Components.Create(); rb.Gravity = false; var box = go.Components.Create(); box.Scale = new Vector3( 10 ); var rdGo = scene.CreateObject(); var rd = rdGo.Components.Create( false ); rd.Radius = 200f; rd.DamageAmount = 0f; rd.Occlusion = false; rd.Enabled = true; // The push is a force - it integrates on the next physics step scene.GameTick(); Assert.IsTrue( rb.Velocity.x > 0f, $"the body should be pushed away from the center: {rb.Velocity}" ); } /// /// With DamageOnEnabled off nothing happens on enable - damage only applies when /// Apply is called explicitly. /// [TestMethod] public void RadiusDamageManualApply() { var scene = new Scene(); using var sceneScope = scene.Push(); var (_, counter) = CreateVictim( scene, new Vector3( 50, 0, 0 ) ); var rdGo = scene.CreateObject(); var rd = rdGo.Components.Create( false ); rd.Radius = 200f; rd.DamageAmount = 40f; rd.DamageOnEnabled = false; rd.Occlusion = false; rd.Enabled = true; Assert.AreEqual( 0, counter.Events, "no damage should apply on enable" ); rd.Apply(); Assert.AreEqual( 1, counter.Events ); Assert.AreEqual( 30f, counter.LastAmount, 0.5f, "40 damage at a quarter of the radius leaves 30" ); } /// /// With occlusion enabled and nothing tagged "map" in the way, the line of sight /// check passes and damage still applies. /// [TestMethod] public void RadiusDamageWithOcclusionStillHits() { var scene = new Scene(); using var sceneScope = scene.Push(); var (_, counter) = CreateVictim( scene, new Vector3( 60, 0, 0 ) ); var rdGo = scene.CreateObject(); var rd = rdGo.Components.Create( false ); rd.Radius = 200f; rd.DamageAmount = 50f; rd.Enabled = true; Assert.AreEqual( 1, counter.Events, "an unoccluded target should take damage" ); Assert.IsTrue( counter.LastAmount > 0f ); } /// /// FireDamage burns every IDamageable under the root object on a fixed interval: /// each event is DamagePerSecond * 0.2 damage, tagged fire and burn. /// [TestMethod] public void FireDamageBurnsRootPeriodically() { var scene = new Scene(); using var sceneScope = scene.Push(); var root = scene.CreateObject(); var counter = root.Components.Create(); var fireGo = scene.CreateObject(); fireGo.Parent = root; fireGo.LocalPosition = new Vector3( 5, 0, 0 ); using ( GameComponentTestUtils.PushSceneClock( scene ) ) { fireGo.Components.Create(); } for ( int i = 0; i < 20; i++ ) scene.GameTick(); Assert.IsTrue( counter.Events >= 4, $"the fire should burn repeatedly: {counter.Events}" ); Assert.IsTrue( counter.Events <= 12, $"the burn is limited to the damage interval: {counter.Events}" ); Assert.AreEqual( 4f, counter.LastAmount, 0.01f, "each event deals DamagePerSecond * interval" ); Assert.IsTrue( counter.Last.Tags.Has( "fire" ) ); Assert.IsTrue( counter.Last.Tags.Has( "burn" ) ); Assert.IsTrue( counter.Last.Origin.AlmostEqual( new Vector3( 5, 0, 0 ) ) ); } /// /// TriggerHurt and RadiusDamage keep their configured values through a /// serialization round trip, and FireDamage survives as a component. /// [TestMethod] public void DamageComponentSerialization() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var hurt = go.Components.Create(); hurt.Damage = 25f; hurt.Rate = 2.5f; hurt.Include.Add( "victim" ); var rd = go.Components.Create( false ); rd.Radius = 99f; rd.DamageAmount = 12f; rd.PhysicsForceScale = 3f; rd.DamageOnEnabled = false; rd.Occlusion = false; go.Components.Create( false ); var copy = GameComponentTestUtils.RoundTrip( go ); var hurt2 = copy.Components.Get( true ); Assert.IsNotNull( hurt2 ); Assert.AreEqual( 25f, hurt2.Damage, 0.01f ); Assert.AreEqual( 2.5f, hurt2.Rate, 0.01f ); Assert.IsTrue( hurt2.Include.Has( "victim" ) ); var rd2 = copy.Components.Get( true ); Assert.IsNotNull( rd2 ); Assert.AreEqual( 99f, rd2.Radius, 0.01f ); Assert.AreEqual( 12f, rd2.DamageAmount, 0.01f ); Assert.AreEqual( 3f, rd2.PhysicsForceScale, 0.01f ); Assert.IsFalse( rd2.DamageOnEnabled ); Assert.IsFalse( rd2.Occlusion ); Assert.IsNotNull( copy.Components.Get( true ) ); } } /// /// Pins the mapping logic components: the Door state machine (rotating and sliding, /// locking, auto close, linked doors, pressing) and the Button state machine /// (toggle, auto reset, immediate, continuous and movement animation). /// [TestClass] public class MapLogicTest { Connection _previousLocalConnection; NetworkSystem _previousNetworkSystem; /// /// Pins Connection.Local to a host connection and clears Networking.System so the /// host-gated entry points (Door.Toggle is [Rpc.Host], and the door/button RPCs /// route through Rpc dispatch) execute locally instead of being forwarded or /// silently dropped when another test in the assembly has leaked a non-host /// client connection. Same idiom as ChairTests.cs. /// [TestInitialize] public void PinHostNetworkingState() { _previousLocalConnection = Connection.Local; _previousNetworkSystem = Networking.System; Connection.Local = new TestConnection( Guid.NewGuid(), isHost: true ); Networking.System = null; } /// /// Restores whatever global networking state existed before the test, leaving the /// rest of the assembly exactly as it was. /// [TestCleanup] public void RestoreNetworkingState() { Connection.Local = _previousLocalConnection; Networking.System = _previousNetworkSystem; } /// /// Creates a door on a fresh GameObject and ticks once so OnStart captures the /// start transform and settles the initial state. /// static (GameObject go, Door door) CreateDoor( Scene scene ) { var go = scene.CreateObject(); var door = go.Components.Create(); return (go, door); } /// /// A rotating door starts Closed after OnStart (before that the state defaults to /// Open - the enum's zero value), opens through the Opening state to a yaw of /// TargetAngle, and closes back through Closing to its start rotation. /// [TestMethod] public void DoorRotatingOpenCloseStateMachine() { var scene = new Scene(); using var sceneScope = scene.Push(); var (go, door) = CreateDoor( scene ); // Before OnStart the state is the enum default - Open Assert.AreEqual( Door.DoorState.Open, door.State ); scene.GameTick(); Assert.AreEqual( Door.DoorState.Closed, door.State, "the door starts closed" ); using ( GameComponentTestUtils.PushSceneClock( scene ) ) { door.Open(); } Assert.AreEqual( Door.DoorState.Opening, door.State, "opening starts synchronously" ); // Part way through the animation the door has rotated but isn't done for ( int i = 0; i < 3; i++ ) scene.GameTick(); Assert.AreEqual( Door.DoorState.Opening, door.State ); var midYaw = MathF.Abs( go.WorldRotation.Angles().yaw ); Assert.IsTrue( midYaw > 1f && midYaw < 89f, $"the door should be mid swing: {midYaw}" ); GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Open, 40 ); Assert.AreEqual( Door.DoorState.Open, door.State ); Assert.AreEqual( 90f, MathF.Abs( go.WorldRotation.Angles().yaw ), 1f ); using ( GameComponentTestUtils.PushSceneClock( scene ) ) { door.Close(); } Assert.AreEqual( Door.DoorState.Closing, door.State ); GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Closed, 40 ); Assert.AreEqual( Door.DoorState.Closed, door.State ); Assert.AreEqual( 0f, MathF.Abs( go.WorldRotation.Angles().yaw ), 1f ); } /// /// A sliding door moves to its SlideOffset when open and back when closed, at /// Speed units per second. /// [TestMethod] public void DoorSlidingMovesBySlideOffset() { var scene = new Scene(); using var sceneScope = scene.Push(); var (go, door) = CreateDoor( scene ); door.Mode = Door.DoorMode.Sliding; door.SlideOffset = new Vector3( 0, 0, 50 ); door.Speed = 100f; scene.GameTick(); Assert.AreEqual( Door.DoorState.Closed, door.State ); using ( GameComponentTestUtils.PushSceneClock( scene ) ) { door.Open(); } GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Open, 40 ); Assert.AreEqual( Door.DoorState.Open, door.State ); Assert.AreEqual( 50f, go.LocalPosition.z, 1f, $"{go.LocalPosition}" ); using ( GameComponentTestUtils.PushSceneClock( scene ) ) { door.Close(); } GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Closed, 40 ); Assert.AreEqual( 0f, go.LocalPosition.z, 1f, $"{go.LocalPosition}" ); } /// /// A locked door refuses to open until unlocked. /// [TestMethod] public void DoorLockedRefusesToOpen() { var scene = new Scene(); using var sceneScope = scene.Push(); var (_, door) = CreateDoor( scene ); door.IsLocked = true; scene.GameTick(); using ( GameComponentTestUtils.PushSceneClock( scene ) ) { door.Open(); } Assert.AreEqual( Door.DoorState.Closed, door.State, "a locked door stays closed" ); for ( int i = 0; i < 5; i++ ) scene.GameTick(); Assert.AreEqual( Door.DoorState.Closed, door.State ); door.IsLocked = false; using ( GameComponentTestUtils.PushSceneClock( scene ) ) { door.Open(); } Assert.AreEqual( Door.DoorState.Opening, door.State, "an unlocked door opens" ); } /// /// With AutoClose enabled the door closes itself after the delay. /// [TestMethod] public void DoorAutoClosesAfterDelay() { var scene = new Scene(); using var sceneScope = scene.Push(); var (_, door) = CreateDoor( scene ); door.AutoClose = true; door.AutoCloseDelay = 0.2f; scene.GameTick(); using ( GameComponentTestUtils.PushSceneClock( scene ) ) { door.Open(); } Assert.AreEqual( Door.DoorState.Opening, door.State ); // The door swings fully open, waits out the delay and closes itself again. // The open state can be passed within a single tick, so wait for the round trip. GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Closed, 80 ); Assert.AreEqual( Door.DoorState.Closed, door.State, "the door should close itself" ); } /// /// StartOpen places the door in the open pose at start, and it can be closed /// back to its captured start transform from there. /// [TestMethod] public void DoorStartOpenBeginsOpen() { var scene = new Scene(); using var sceneScope = scene.Push(); var (go, door) = CreateDoor( scene ); door.StartOpen = true; scene.GameTick(); Assert.AreEqual( Door.DoorState.Open, door.State ); Assert.AreEqual( 90f, MathF.Abs( go.WorldRotation.Angles().yaw ), 1f ); using ( GameComponentTestUtils.PushSceneClock( scene ) ) { door.Close(); } GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Closed, 40 ); Assert.AreEqual( Door.DoorState.Closed, door.State ); Assert.AreEqual( 0f, MathF.Abs( go.WorldRotation.Angles().yaw ), 1f ); } /// /// A linked door back-links itself on start and opens and closes together with /// its partner. /// [TestMethod] public void DoorLinkedDoorFollows() { var scene = new Scene(); using var sceneScope = scene.Push(); var (_, doorA) = CreateDoor( scene ); var (_, doorB) = CreateDoor( scene ); doorA.LinkedDoor = doorB; scene.GameTick(); Assert.AreEqual( doorA, doorB.LinkedDoor, "the linked door should back-link on start" ); using ( GameComponentTestUtils.PushSceneClock( scene ) ) { doorA.Open(); } Assert.AreEqual( Door.DoorState.Opening, doorA.State ); Assert.AreEqual( Door.DoorState.Opening, doorB.State, "the linked door opens too" ); GameComponentTestUtils.TickUntil( scene, () => doorA.State == Door.DoorState.Open && doorB.State == Door.DoorState.Open, 40 ); Assert.AreEqual( Door.DoorState.Open, doorA.State ); Assert.AreEqual( Door.DoorState.Open, doorB.State ); using ( GameComponentTestUtils.PushSceneClock( scene ) ) { doorA.Close(); } Assert.AreEqual( Door.DoorState.Closing, doorB.State, "the linked door closes too" ); } /// /// Pressing the door through IPressable toggles it. While the door is animating /// CanPress reports false. /// [TestMethod] public void DoorPressToggles() { var scene = new Scene(); using var sceneScope = scene.Push(); var (_, door) = CreateDoor( scene ); var presserGo = scene.CreateObject(); presserGo.WorldPosition = new Vector3( 50, 0, 0 ); var presser = presserGo.Components.Create(); var pressEvent = new Component.IPressable.Event( presser ); scene.GameTick(); var pressable = (Component.IPressable)door; Assert.IsTrue( pressable.CanPress( pressEvent ), "a resting door can be pressed" ); using ( GameComponentTestUtils.PushSceneClock( scene ) ) { Assert.IsTrue( pressable.Press( pressEvent ) ); } Assert.AreEqual( Door.DoorState.Opening, door.State ); Assert.IsFalse( pressable.CanPress( pressEvent ), "an animating door can't be pressed" ); GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Open, 40 ); Assert.IsTrue( pressable.CanPress( pressEvent ) ); using ( GameComponentTestUtils.PushSceneClock( scene ) ) { pressable.Press( pressEvent ); } Assert.AreEqual( Door.DoorState.Closing, door.State, "pressing an open door closes it" ); } /// /// A toggle button with AutoReset turns itself back off after the reset time. /// TurnOff during the on-animation is ignored. /// [TestMethod] public void ButtonToggleAutoResets() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var button = go.Components.Create