diff --git a/Core/World/Entities/Definition/States/FrameState.cs b/Core/World/Entities/Definition/States/FrameState.cs index 13606ec7f..5e05880de 100644 --- a/Core/World/Entities/Definition/States/FrameState.cs +++ b/Core/World/Entities/Definition/States/FrameState.cs @@ -271,7 +271,7 @@ public void Tick(Entity entity) return; CurrentTick--; - if (CurrentTick <= 0) + if (CurrentTick == 0) { if (Frame.BranchType == ActorStateBranch.Stop && (Options & FrameStateOptions.DestroyOnStop) != 0) { diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 6b74c3412..d40b71747 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -27,6 +27,7 @@ - Fix sky texture not working when it's not in the texture namespace. - Right-extend "block" characters in ENDOOM to emulate VGA "line graphics enable" mode. - Fix A_Refire not calling noise alert. +- Match Doom behavior in the thing tick function that wouldn't advance the state for zero duration frames and leave them in a -1 loop. ## Misc: - Refactor of old Status Bar renderer to data-driven SBARDEF format. diff --git a/Tests/Unit/GameAction/Dehacked/FrameStates.cs b/Tests/Unit/GameAction/Dehacked/FrameStates.cs new file mode 100644 index 000000000..0206ac5b6 --- /dev/null +++ b/Tests/Unit/GameAction/Dehacked/FrameStates.cs @@ -0,0 +1,77 @@ +using FluentAssertions; +using Helion.Geometry.Vectors; +using Helion.Resources.IWad; +using Helion.World.Impl.SinglePlayer; +using Xunit; + +namespace Helion.Tests.Unit.GameAction.Dehacked; + +[Collection("GameActions")] +public class FrameStates +{ + private readonly SinglePlayerWorld World; + + public FrameStates() + { + World = WorldAllocator.LoadMap("Resources/box.zip", "box.WAD", "MAP01", GetType().Name, (world) => { }, IWadType.Doom2); + } + + [Fact(DisplayName = "Setting null death state through missile removes entity")] + public void NullDeathStateRemovesEntity() + { + var def = World.EntityManager.DefinitionComposer.GetByName("DoomImp")!; + def.DeathState = null; + def.Flags.SetSpawnCeiling(); + def.Flags.SetMissile(); + + var entity = GameActions.CreateEntity(World, "DoomImp", Vec3D.Zero, initSpawn: true); + entity.IsDisposed.Should().BeFalse(); + GameActions.TickWorld(World, 35); + entity.IsDisposed.Should().BeTrue(); + } + + [Fact(DisplayName = "Zero tick duration spawn state set -1 and loops")] + public void ZeroTickDurationSpawnState() + { + // The tick function didn't have the same check and wouldn't let a zero duration frame immediately go to the next. + // It would decrement and check if it didn't equal zero and this causes it to be infinitely in a -1 frame. + var def = World.EntityManager.DefinitionComposer.GetByName("DoomImp")!; + var frame = World.ArchiveCollection.EntityFrameTable.Frames[def.SpawnState!.Value]; + frame.Ticks = 0; + frame.NextFrameIndex = frame.MasterFrameIndex; + + var entity = GameActions.CreateEntity(World, "DoomImp", Vec3D.Zero, initSpawn: true); + entity.FrameState.Frame.Should().Be(frame); + entity.FrameState.CurrentTick.Should().Be(0); + entity.Tick(); + entity.FrameState.Frame.Should().Be(frame); + entity.FrameState.CurrentTick.Should().Be(-1); + entity.Tick(); + entity.FrameState.Frame.Should().Be(frame); + entity.FrameState.CurrentTick.Should().Be(-1); + } + + [Fact(DisplayName = "Zero tick duration after spawn state automatically goes to next frame")] + public void ZeroTickDurationAfterSpawnState() + { + var def = World.EntityManager.DefinitionComposer.GetByName("DoomImp")!; + var frame = World.ArchiveCollection.EntityFrameTable.Frames[def.SpawnState!.Value]; + frame.ActionFunction = null; + frame.Ticks = 1; + frame.NextFrameIndex = frame.MasterFrameIndex + 1; + var nextFrame = frame.NextFrame; + nextFrame.ActionFunction = null; + nextFrame.Ticks = 0; + nextFrame.NextFrameIndex = nextFrame.MasterFrameIndex + 1; + var lastFrame = nextFrame.NextFrame; + lastFrame.ActionFunction = null; + lastFrame.Ticks = 69; + + var entity = GameActions.CreateEntity(World, "DoomImp", Vec3D.Zero, initSpawn: true); + entity.FrameState.Frame.Should().Be(frame); + entity.FrameState.CurrentTick.Should().Be(1); + entity.Tick(); + entity.FrameState.Frame.Should().Be(lastFrame); + entity.FrameState.CurrentTick.Should().Be(69); + } +} diff --git a/Tests/Unit/GameAction/Dehacked/NullState.cs b/Tests/Unit/GameAction/Dehacked/NullState.cs deleted file mode 100644 index 02f68da6f..000000000 --- a/Tests/Unit/GameAction/Dehacked/NullState.cs +++ /dev/null @@ -1,35 +0,0 @@ -using FluentAssertions; -using Helion.Geometry.Vectors; -using Helion.Resources.IWad; -using Helion.Tests.Unit.GameAction.Util; -using Helion.World; -using Helion.World.Entities.Players; -using Helion.World.Impl.SinglePlayer; -using Xunit; - -namespace Helion.Tests.Unit.GameAction.Dehacked; - -[Collection("GameActions")] -public class NullState -{ - private readonly SinglePlayerWorld World; - - public NullState() - { - World = WorldAllocator.LoadMap("Resources/box.zip", "box.WAD", "MAP01", GetType().Name, (world) => { }, IWadType.Doom2); - } - - [Fact(DisplayName = "Setting null death state through missile removes entity")] - public void NullDeathStateRemovesEntity() - { - var def = World.EntityManager.DefinitionComposer.GetByName("DoomImp")!; - def.DeathState = null; - def.Flags.SetSpawnCeiling(); - def.Flags.SetMissile(); - - var entity = GameActions.CreateEntity(World, "DoomImp", Vec3D.Zero, initSpawn: true); - entity.IsDisposed.Should().BeFalse(); - GameActions.TickWorld(World, 35); - entity.IsDisposed.Should().BeTrue(); - } -}