revert old sim groundwork

This commit is contained in:
2026-04-21 17:00:51 +02:00
parent be68ac9fc1
commit 551757d521
66 changed files with 7 additions and 35606 deletions

View File

@@ -4,11 +4,9 @@
This repository should be organized as a Godot 4 .NET project from the root:
- `project.godot` project entrypoint
- `SideScrollerGame.csproj` and optional solution file
- `scenes/` playable scenes such as `scenes/player/Player.tscn`
- `scripts/` C# gameplay code paired with scenes
- `assets/` sprites, audio, fonts, and tile sets
- `tests/SideScrollerGame.Tests/` .NET test project for engine-light logic
- `addons/` third-party Godot plugins when needed
- `build/` local export output; do not commit generated files unless release workflow requires it
@@ -19,14 +17,12 @@ Run commands from the repository root. On Windows PowerShell, use the repo-local
- `.\godot --editor --path .` opens the project in the editor
- `.\godot --headless --path . --build-solutions` builds generated C# project files through Godot
- `dotnet build` compiles the game assemblies and catches regular C# build errors
- `dotnet test tests/SideScrollerGame.Tests` runs automated unit tests
- `.\godot --headless --path . --export-release "Windows Desktop" ./build/SideScrollerGame.exe` exports a desktop build when presets are configured
- `.\godot --headless --path . --export-release "Windows Desktop" ./build/zfxaction26_1.exe` exports a desktop build when presets are configured
Document any custom wrapper scripts in `README.md` if the team adds them later.
## Coding Style & Naming Conventions
Use 4 spaces for indentation, nullable reference types, and one primary public type per file. Keep Godot node scripts thin and move reusable gameplay rules into plain C# classes that do not depend on scene state.
For C#, use 4 spaces for indentation, nullable reference types, and one primary public type per file. Keep Godot node scripts thin and move reusable gameplay rules into plain C# classes that do not depend on scene state.
- `PascalCase` for classes, methods, properties, scene files, and C# scripts: `PlayerController`, `PlayerController.cs`
- `m_PascalCase` for private fields
@@ -45,19 +41,13 @@ Keep a strict element order inside of types:
- Events
- Fields
## Testing Guidelines
Use a .NET test project under `tests/` and mirror gameplay areas, for example `tests/SideScrollerGame.Tests/Player/PlayerControllerTests.cs`. Prefer xUnit-style or NUnit-style tests for movement math, combat rules, save/load logic, and other engine-light systems.
Add regression tests for every gameplay bug fix when practical. If scene-level automation is added later, keep it separate from fast unit tests.
## Working rules
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.6.0. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative.
- PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python. Run Python code using python -c with inline commands instead of python - <<'PY'.
- Follow operating system specific rules, either AGENTS.windows.md or AGENTS.linux.md
- Before beginning with the edit phase, always present a plan first. Only begin editing after the user approves the plan.
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
- After every iteration, before committing, run `jb cleanupcode --build=False $file1;$file2;...` for every file you touched.
- After every iteration, before committing, run `jb cleanupcode --build=False $file1;$file2;...` for every C# file you touched.
- After every iteration, if there's a relevant documentation for the current task, update it according to the change.
- Update the wording of touched concerns instead of introducing incremental change reports
- The documentation should always represent the current state in its entirety and not derail into a historical development log.
@@ -70,10 +60,10 @@ Add regression tests for every gameplay bug fix when practical. If scene-level a
## Output generation
### User
- For the user, you talk like a caveman. Speak only short grunts. Communicate in english.
- For the user, you talk like a caveman. Speak only short grunts, conveying as much information in as few words as possible. Communicate in english.
- Give no explanations unless explicitly asked.
- No fillers like 'happy to help'. Do task first. Show result. Stop.
- For tools: "Tool work." then output the result and words like "Problem.", "Plan?", "Done."
- No fillers like 'happy to help' or 'grunt'. Do task first. Show result. Stop.
- For tools: "Tool work." then output the result and words like "Question:", "Problem.", "Plan?", "Done."
- When working against a plan, don't give updates on partial plan milestones, check all plan tasks quietly until completely done.
### Reasoning

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
uid://d1ymd8m2to6u

View File

@@ -1,386 +0,0 @@
#if DEBUG
#define RANGE_CHECK
#endif
using System;
using System.Runtime.CompilerServices;
namespace MagmaEngine.Math;
public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquatable<FixPoint16Long>
{
public FixPoint16Long(FixPoint16Long other)
{
m_Value = other.m_Value;
}
public FixPoint16Long(int value)
{
m_Value = (long)value << c_Shift;
}
public FixPoint16Long(long value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
{
throw new ArithmeticException($"Long to FixPoint argument out of range: {value}");
}
#endif
m_Value = value << c_Shift;
}
public FixPoint16Long(double value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
{
throw new ArithmeticException($"Double to FixPoint argument out of range: {value}");
}
#endif
if (value < 0.0)
{
m_Value = (int)((value * c_Multiplier) - 0.5);
}
else
{
m_Value = (int)((value * c_Multiplier) + 0.5);
}
}
public FixPoint16Long(float value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
{
throw new ArithmeticException($"Single to FixPoint argument out of range: {value}");
}
#endif
if (value < 0.0f)
{
m_Value = (int)((value * c_MultiplierFloat) - 0.5f);
}
else
{
m_Value = (int)((value * c_MultiplierFloat) + 0.5f);
}
}
public long ToLongFloor()
{
return m_Value >> c_Shift;
}
public long ToLongCeil()
{
return (m_Value + c_FractionMask) >> c_Shift;
}
public long ToLongRound()
{
if (m_Value < 0)
{
return -((-m_Value + c_Half) >> c_Shift);
}
return (m_Value + c_Half) >> c_Shift;
}
public long ToLong()
{
if (m_Value < 0)
{
return -(-m_Value >> c_Shift);
}
return m_Value >> c_Shift;
}
public double ToDouble()
{
return c_Divisor * m_Value;
}
public float ToFloat()
{
return c_DivisorFloat * m_Value;
}
public override string ToString()
{
return $"{ToDouble()}[0x{m_Value:x16}]";
}
public override int GetHashCode()
{
return m_Value.GetHashCode();
}
public int CompareTo(object? obj)
{
if (obj is not FixPoint16Long other)
return -1;
return m_Value.CompareTo(other.m_Value);
}
public int CompareTo(FixPoint16Long other)
{
return m_Value.CompareTo(other.m_Value);
}
public override bool Equals(object? obj)
{
if (obj == null)
{
return false;
}
return ((FixPoint16Long)obj).m_Value == m_Value;
}
public bool Equals(FixPoint16Long other)
{
return other.m_Value == m_Value;
}
public bool IsZero()
{
return m_Value == 0L;
}
public static bool operator ==(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value == b.m_Value;
}
public static bool operator !=(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value != b.m_Value;
}
public static bool operator <(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value < b.m_Value;
}
public static bool operator >(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value > b.m_Value;
}
public static bool operator <=(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value <= b.m_Value;
}
public static bool operator >=(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value >= b.m_Value;
}
public static FixPoint16Long operator <<(FixPoint16Long a, int shift)
{
return new() { m_Value = a.m_Value << shift };
}
public static FixPoint16Long operator >> (FixPoint16Long a, int shift)
{
return new() { m_Value = a.m_Value >> shift };
}
public static FixPoint16Long operator +(FixPoint16Long a, FixPoint16Long b)
{
return new() { m_Value = a.m_Value + b.m_Value };
}
public static FixPoint16Long operator -(FixPoint16Long a, FixPoint16Long b)
{
return new() { m_Value = a.m_Value - b.m_Value };
}
public static FixPoint16Long operator *(FixPoint16Long a, FixPoint16Long b)
{
Int128 bigA = a.m_Value;
Int128 bigB = b.m_Value;
var result = ((bigA * bigB) + c_Half) >> c_Shift;
#if RANGE_CHECK
if (result < long.MinValue || result > long.MaxValue)
{
throw new ArithmeticException($"Multiplication result out of range: {result}");
}
#endif
return new() { m_Value = (long)result };
}
public static FixPoint16Long operator /(FixPoint16Long a, FixPoint16Long b)
{
#if RANGE_CHECK
if (b.m_Value == 0)
{
throw new ArithmeticException("Divison by zero");
}
#endif
Int128 result;
if (((ulong)a.m_Value & 0x8000000000000000UL) == ((ulong)b.m_Value & 0x8000000000000000UL))
{
result = (((Int128)a.m_Value << c_Shift) + (b.m_Value / 2)) / b.m_Value;
}
else
{
result = (((Int128)a.m_Value << c_Shift) - (b.m_Value / 2)) / b.m_Value;
}
#if RANGE_CHECK
if (result < long.MinValue || result > long.MaxValue)
{
throw new ArithmeticException($"Division result out of range: {result}");
}
#endif
return new() { m_Value = (long)result };
}
public static FixPoint16Long operator -(FixPoint16Long a)
{
return new() { m_Value = -a.m_Value };
}
public static FixPoint16Long operator *(FixPoint16Long a, int value)
{
return new() { m_Value = a.m_Value * value };
}
public static FixPoint16Long operator /(FixPoint16Long a, int value)
{
#if RANGE_CHECK
if (value == 0)
{
throw new ArithmeticException("Divison by zero");
}
#endif
if (((a.m_Value >> 32) & 0x80000000) == (value & 0x80000000))
{
return new() { m_Value = (long)(((Int128)a.m_Value + (value / 2)) / value) };
}
return new() { m_Value = (long)(((Int128)a.m_Value - (value / 2)) / value) };
}
public static implicit operator FixPoint16Long(int value)
{
return new(value);
}
public static implicit operator FixPoint16Long(long value)
{
return new(value);
}
public static implicit operator FixPoint16Long(FixPoint16 value)
{
return new() { m_Value = value.m_Value };
}
public static explicit operator FixPoint16Long(double value)
{
return new(value);
}
public static explicit operator FixPoint16Long(float value)
{
return new(value);
}
public static explicit operator int(FixPoint16Long value)
{
return (int)value.ToLong();
}
public static explicit operator long(FixPoint16Long value)
{
return value.ToLong();
}
public static explicit operator double(FixPoint16Long value)
{
return value.ToDouble();
}
public static explicit operator float(FixPoint16Long value)
{
return value.ToFloat();
}
public static FixPoint16Long Floor(FixPoint16Long value)
{
return value.ToLongFloor();
}
public static FixPoint16Long Ceil(FixPoint16Long value)
{
return value.ToLongCeil();
}
public static FixPoint16Long Round(FixPoint16Long value)
{
return value.ToLongRound();
}
public static int Sign(FixPoint16Long value)
{
return System.Math.Sign(value.m_Value);
}
public static FixPoint16Long Abs(FixPoint16Long value)
{
return new() { m_Value = System.Math.Abs(value.m_Value) };
}
public static FixPoint16Long Min(FixPoint16Long value1, FixPoint16Long value2)
{
return new() { m_Value = System.Math.Min(value1.m_Value, value2.m_Value) };
}
public static FixPoint16Long Max(FixPoint16Long value1, FixPoint16Long value2)
{
return new() { m_Value = System.Math.Max(value1.m_Value, value2.m_Value) };
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16Long Length(FixPoint16Long a, FixPoint16Long b)
{
var aSquared = a.m_Value * a.m_Value;
var bSquared = b.m_Value * b.m_Value;
var value = aSquared + bSquared;
#if RANGE_CHECK
if (value < 0)
{
throw new ArithmeticException($"Length squared out of range: {value}");
}
#endif
return new() { m_Value = IntMath.Sqrt(value) };
}
private const int c_Shift = 16;
private const long c_IntegerMin = -140737488355327L;
private const long c_IntegerMax = 140737488355327L;
private const long c_Half = 32786L;
private const long c_FractionMask = 0x000000000000ffffL;
private const double c_Multiplier = 65536.0;
private const double c_Divisor = 1.0 / 65536.0;
private const float c_MultiplierFloat = 65536.0f;
private const float c_DivisorFloat = (float)c_Divisor;
public long m_Value;
public static readonly FixPoint16Long Zero = new() { m_Value = 0 };
public static readonly FixPoint16Long MinValue = new() { m_Value = long.MinValue };
public static readonly FixPoint16Long MaxValue = new() { m_Value = long.MaxValue };
public static readonly FixPoint16Long Epsilon = new() { m_Value = 1 };
public static readonly FixPoint16Long MinusEpsilon = new() { m_Value = -1 };
public static readonly FixPoint16Long One = new(1);
public static readonly FixPoint16Long MinusOne = new(-1);
public static readonly FixPoint16Long Half = new() { m_Value = One.m_Value / 2 };
public static readonly FixPoint16Long MinusHalf = -Half;
}

View File

@@ -1 +0,0 @@
uid://i21oxan7sd8p

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
uid://c10dgxlu0d17c

View File

@@ -1,69 +0,0 @@
namespace MagmaEngine.Math;
public static class FixPointUtil
{
public static long DistancePointToSegmentSquared(FixPointVector2 point, FixPointVector2 segmentStart, FixPointVector2 segmentEnd)
{
var segment = segmentEnd - segmentStart;
var toStart = point - segmentStart;
var segmentLengthSquared = FixPointVector2.DotLong(segment, segment);
if (segmentLengthSquared == 0L)
return FixPointVector2.DotLong(toStart, toStart);
var projection = FixPointVector2.DotLong(toStart, segment);
if (projection <= 0)
return FixPointVector2.DotLong(toStart, toStart);
FixPointVector2 toPointOnSegment;
if (projection >= segmentLengthSquared)
toPointOnSegment = point - segmentEnd;
else
toPointOnSegment = toStart - segment * FixPoint16.FromRational(projection, segmentLengthSquared);
return FixPointVector2.DotLong(toPointOnSegment, toPointOnSegment);
}
public static FixPoint16 DistancePointToSegment(FixPointVector2 point, FixPointVector2 segmentStart, FixPointVector2 segmentEnd)
{
return FixPoint16.SqrtLong(DistancePointToSegmentSquared(point, segmentStart, segmentEnd));
}
public static bool LineLineIntersection(FixPointVector2 line1Start, FixPointVector2 line1End, FixPointVector2 line2Start, FixPointVector2 line2End, out FixPointVector2 intersectionPoint)
{
intersectionPoint = default;
var s1 = line1End - line1Start;
var s2 = line2End - line2Start;
var det = (FixPoint16Long)(-s2.m_X) * s1.m_Y + (FixPoint16Long)(s1.m_X) * s2.m_Y;
if (FixPoint16Long.Abs(det) < s_IntersectionEpsilon)
return false;
var t = ((FixPoint16Long)s2.m_X * (line1Start.m_Y - line2Start.m_Y) - (FixPoint16Long)s2.m_Y * (line1Start .m_X - line2Start.m_X)) / det;
var px = line1Start.m_X + (t * s1.m_X);
var py = line1Start.m_Y + (t * s1.m_Y);
if (px.m_Value > s_LineIntersectionMax || px.m_Value < s_LineIntersectionMin || py.m_Value > s_LineIntersectionMax || py.m_Value < s_LineIntersectionMin)
return false;
intersectionPoint = new FixPointVector2(FixPoint16.FromValue(px.m_Value), FixPoint16.FromValue(py.m_Value));
return true;
}
public static FixPoint16 MultiplyClamped(FixPoint16 a, FixPoint16 b)
{
var iResult = (((long)a.m_Value * b.m_Value) + FixPoint16.c_Half) >> FixPoint16.c_Shift;
if (iResult < FixPoint16.c_LongMin)
iResult = FixPoint16.c_LongMin;
else if (iResult > FixPoint16.c_LongMax)
iResult = FixPoint16.c_LongMax;
return new() { m_Value = (int)iResult };
}
private static readonly FixPoint16 s_IntersectionEpsilon = FixPoint16.Epsilon * 30;
private static readonly long s_LineIntersectionMax = FixPoint16.c_LongMax / 4;
private static readonly long s_LineIntersectionMin = FixPoint16.c_LongMin / 4;
}

View File

@@ -1 +0,0 @@
uid://bxrdtopa5ct2u

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
uid://cn1byd6pp54cv

View File

@@ -1,993 +0,0 @@
using System;
using System.Diagnostics.Contracts;
using System.Numerics;
using System.Runtime.CompilerServices;
namespace MagmaEngine.Math;
public struct SFixPointVector3 : IEquatable<SFixPointVector3>
{
/// <summary>
/// Initializes a new instance of FixPointVector3
/// </summary>
/// <param name="x">Initial value for the x-component of the vector.</param>
/// <param name="y">Initial value for the y-component of the vector.</param>
/// <param name="z">Initial value for the z-component of the vector.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public SFixPointVector3(int x, int y, int z)
{
m_X = x;
m_Y = y;
m_Z = z;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public SFixPointVector3(FixPoint16 x, FixPoint16 y, FixPoint16 z)
{
m_X = x;
m_Y = y;
m_Z = z;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public SFixPointVector3(FixPoint16 x)
{
m_X = x;
m_Y = x;
m_Z = x;
}
public SFixPointVector3(float x, float y, float z)
{
m_X = new(x);
m_Y = new(y);
m_Z = new(z);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public SFixPointVector3(Vector3 coord)
{
m_X = new(coord.X);
m_Y = new(coord.Y);
m_Z = new(coord.Z);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static explicit operator SFixPointVector3(Vector3 coord)
{
return new(new(coord.X), new(coord.Y), new FixPoint16(coord.Z));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static explicit operator Vector3(SFixPointVector3 coord)
{
return new(coord.m_X.ToFloat(), coord.m_Y.ToFloat(), coord.m_Z.ToFloat());
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public Vector3 ToVector3()
{
return new(m_X.ToFloat(), m_Y.ToFloat(), m_Z.ToFloat());
}
#region -- base overrides ---------------------------------------------
[Pure]
public override string ToString()
{
return $"({m_X.ToString()}, {m_Y.ToString()}, {m_Z.ToString()})";
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public bool Equals(SFixPointVector3 other)
{
return m_X == other.m_X && m_Y == other.m_Y && m_Z == other.m_Z;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public override bool Equals(object? obj)
{
if (obj is SFixPointVector3 fixPointVector3)
return Equals(fixPointVector3);
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public override int GetHashCode()
{
return m_X.GetHashCode() + m_Y.GetHashCode() + m_Z.GetHashCode();
}
#endregion
#region -- static properties ------------------------------------------
public static readonly SFixPointVector3 s_Zero = new(0, 0, 0);
public static readonly SFixPointVector3 s_One = new(1, 1, 1);
public static readonly SFixPointVector3 s_UnitX = new(1, 0, 0);
public static readonly SFixPointVector3 s_UnitY = new(0, 1, 0);
public static readonly SFixPointVector3 s_UnitZ = new(0, 0, 1);
public static readonly SFixPointVector3 s_MaxValue = new(FixPoint16.MaxValue, FixPoint16.MaxValue, FixPoint16.MaxValue);
public static readonly SFixPointVector3 s_MinValue = new(FixPoint16.MinValue, FixPoint16.MinValue, FixPoint16.MinValue);
#endregion
#region -- public properties -----------------------------------------
public FixPoint16 this[int i]
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get
{
switch (i)
{
case 0: return m_X;
case 1: return m_Y;
case 2: return m_Z;
default: throw new ArgumentOutOfRangeException(nameof(i));
}
}
set
{
switch (i)
{
case 0:
m_X = value;
break;
case 1:
m_Y = value;
break;
case 2:
m_Z = value;
break;
default: throw new ArgumentOutOfRangeException(nameof(i));
}
}
}
/// <summary>
/// Returns a new normalized FixPointVector3 from the current vector.
/// </summary>
public SFixPointVector3 Normalized
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get
{
var length = Length();
if (!length.IsZero())
{
SFixPointVector3 result = new(m_X / length, m_Y / length, m_Z / length);
return result;
}
throw new InvalidOperationException("Error: can not normalize vector, the vector length is zero.");
}
}
public FixPointVector2 XY
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get => new(m_X, m_Y);
}
public FixPointVector2 XZ
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get => new(m_X, m_Z);
}
#endregion
#region -- public methods ---------------------------------------------
/// <summary>
/// Calculates the length of the current vector.
/// </summary>
/// <returns>The Length of the current vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public FixPoint16 Length()
{
return FixPoint16.Length(m_X, m_Y, m_Z);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public int CompareLength(FixPoint16 length)
{
unchecked
{
return ((m_X.m_Value * (long)m_X.m_Value) + (m_Y.m_Value * (long)m_Y.m_Value) + (m_Z.m_Value * (long)m_Z.m_Value)).CompareTo(length.m_Value * (long)length.m_Value);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public long LengthSquaredLong()
{
unchecked
{
return (m_X.m_Value * (long)m_X.m_Value) + (m_Y.m_Value * (long)m_Y.m_Value) + (m_Z.m_Value * (long)m_Z.m_Value);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public int CompareLength(SFixPointVector3 other)
{
unchecked
{
return ((m_X.m_Value * (long)m_X.m_Value) + (m_Y.m_Value * (long)m_Y.m_Value) + (m_Z.m_Value * (long)m_Z.m_Value)).CompareTo(
(other.m_X.m_Value * (long)other.m_X.m_Value) + (other.m_Y.m_Value * (long)other.m_Y.m_Value) + (other.m_Z.m_Value * (long)other.m_Z.m_Value));
}
}
/// <summary>
/// Normalizes the current vector
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Normalize()
{
unchecked
{
var length = Length();
if (!length.IsZero())
{
m_X = m_X / length;
m_Y = m_Y / length;
m_Z = m_Z / length;
}
#if DEBUG
else
{
throw new("Error: can not normalize vector, the vector length is zero.");
}
#endif
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public bool IsZero()
{
return m_X.IsZero() && m_Y.IsZero() && m_Z.IsZero();
}
/// <summary>
/// Adds a given scalar value to each component of the current FixPointVector3.
/// </summary>
/// <param name="value">The scalar value</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Add(FixPoint16 value)
{
unchecked
{
m_X = m_X + value;
m_Y = m_Y + value;
m_Z = m_Z + value;
}
}
/// <summary>
/// Adds a given FixPointVector3 to the current FixPointVector3.
/// </summary>
/// <param name="other">The vector to be added.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Add(SFixPointVector3 other)
{
unchecked
{
m_X = m_X + other.m_X;
m_Y = m_Y + other.m_Y;
m_Z = m_Z + other.m_Z;
}
}
/// <summary>
/// Subtracts a scalar value from each component of the current FixPointVector3.
/// </summary>
/// <param name="value">The scalar value.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Subtract(FixPoint16 value)
{
unchecked
{
m_X = m_X - value;
m_Y = m_Y - value;
m_Z = m_Z - value;
}
}
/// <summary>
/// Subtracts a given FixPointVector3 from the current FixPointVector3.
/// </summary>
/// <param name="other">The vector to be subtracted.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Subtract(SFixPointVector3 other)
{
unchecked
{
m_X = m_X - other.m_X;
m_Y = m_Y - other.m_Y;
m_Z = m_Z - other.m_Z;
}
}
/// <summary>
/// Multiplies each component of the current FixPointVector3 by a given scalar value.
/// </summary>
/// <param name="scalar">The scalar value.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Multiply(FixPoint16 scalar)
{
unchecked
{
m_X = m_X * scalar;
m_Y = m_Y * scalar;
m_Z = m_Z * scalar;
}
}
/// <summary>
/// Multiplies the current FixPointVector3 by another FixPointVector3.
/// </summary>
/// <param name="other">The source vector.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Multiply(SFixPointVector3 other)
{
unchecked
{
m_X = m_X * other.m_X;
m_Y = m_Y * other.m_Y;
m_Z = m_Z * other.m_Z;
}
}
/// <summary>
/// Divides each component of the current FixPointVector3 by a given scalar value.
/// </summary>
/// <param name="divider">The scalar divider.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Divide(FixPoint16 divider)
{
#if DEBUG
if (divider.IsZero())
throw new("Error: divider is zero (FixPointVector3.Divide).");
#endif
unchecked
{
m_X = m_X / divider;
m_Y = m_Y / divider;
m_Z = m_Z / divider;
}
}
/// <summary>
/// Divides the current FixPointVector3 by another FixPointVector3.
/// </summary>
/// <param name="other">The vector divider.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Divide(SFixPointVector3 other)
{
unchecked
{
if (!other.m_X.IsZero() && !other.m_Y.IsZero() && !other.m_Z.IsZero())
{
m_X = m_X / other.m_X;
m_Y = m_Y / other.m_Y;
m_Z = m_Z / other.m_Z;
}
#if DEBUG
else
{
throw new("Error: divider vector contains zero (FixPointVector3.Divide).");
}
#endif
}
}
/// <summary>
/// Transforms a FixPointVector3.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public static SFixPointVector3 Transform(SFixPointVector3 v, SFixPointQuaternionTransform t)
{
return Transform(v * t.m_Size, t.m_Orientation) + t.m_Position;
}
/// <summary>
/// Transforms a vector by the given Quaternion rotation value.
/// </summary>
/// <param name="value">The source vector to be rotated.</param>
/// <param name="rotation">The rotation to apply.</param>
/// <returns>The transformed vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Transform(SFixPointVector3 value, SFixPointQuaternion rotation)
{
var x2 = rotation.m_X + rotation.m_X;
var y2 = rotation.m_Y + rotation.m_Y;
var z2 = rotation.m_Z + rotation.m_Z;
var wx2 = rotation.m_W * x2;
var wy2 = rotation.m_W * y2;
var wz2 = rotation.m_W * z2;
var xx2 = rotation.m_X * x2;
var xy2 = rotation.m_X * y2;
var xz2 = rotation.m_X * z2;
var yy2 = rotation.m_Y * y2;
var yz2 = rotation.m_Y * z2;
var zz2 = rotation.m_Z * z2;
return new((value.m_X * (1 - yy2 - zz2)) + (value.m_Y * (xy2 - wz2)) + (value.m_Z * (xz2 + wy2)), (value.m_X * (xy2 + wz2)) + (value.m_Y * (1 - xx2 - zz2)) + (value.m_Z * (yz2 - wx2)),
(value.m_X * (xz2 - wy2)) + (value.m_Y * (yz2 + wx2)) + (value.m_Z * (1 - xx2 - yy2)));
}
#endregion
#region -- public static methods --------------------------------------
/// <summary>
/// Calculates the length of the given vector.
/// </summary>
/// <returns>The Length of the given vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 Length(SFixPointVector3 value)
{
return value.Length();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int CompareLength(SFixPointVector3 value, FixPoint16 fLength)
{
unchecked
{
return ((value.m_X.m_Value * (long)value.m_X.m_Value) + (value.m_Y.m_Value * (long)value.m_Y.m_Value) + (value.m_Z.m_Value * (long)value.m_Z.m_Value)).CompareTo(
fLength.m_Value * (long)fLength.m_Value);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int CompareLength(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
return ((value1.m_X.m_Value * (long)value1.m_X.m_Value) + (value1.m_Y.m_Value * (long)value1.m_Y.m_Value) + (value1.m_Z.m_Value * (long)value1.m_Z.m_Value)).CompareTo(
(value2.m_X.m_Value * (long)value2.m_X.m_Value) + (value2.m_Y.m_Value * (long)value2.m_Y.m_Value) + (value2.m_Z.m_Value * (long)value2.m_Z.m_Value));
}
}
/// <summary>
/// Computes the cross product of two vectors.
/// </summary>
/// <param name="vector1">The first vector.</param>
/// <param name="vector2">The second vector.</param>
/// <returns>The cross product.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Cross(SFixPointVector3 vector1, SFixPointVector3 vector2)
{
return new((vector1.m_Y * vector2.m_Z) - (vector1.m_Z * vector2.m_Y), (vector1.m_Z * vector2.m_X) - (vector1.m_X * vector2.m_Z),
(vector1.m_X * vector2.m_Y) - (vector1.m_Y * vector2.m_X));
}
/// <summary>
/// Calculates the distance between two vectors. (manhatten/taxi-cab metrix)
/// </summary>
/// <param name="left">The source vector</param>
/// <param name="right">The source vector</param>
/// <returns>Distance between the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 DistanceManhattan(SFixPointVector3 left, SFixPointVector3 right)
{
return FixPoint16.Abs(left.m_X - right.m_X) + FixPoint16.Abs(left.m_Y - right.m_Y) + FixPoint16.Abs(left.m_Z - right.m_Z);
}
/// <summary>
/// Calculates the distance between two vectors.
/// </summary>
/// <param name="left">The source vector</param>
/// <param name="right">The source vector</param>
/// <returns>Distance between the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 Distance(SFixPointVector3 left, SFixPointVector3 right)
{
var dx = left.m_X - right.m_X;
var dy = left.m_Y - right.m_Y;
var dz = left.m_Z - right.m_Z;
return FixPoint16.Length(dx, dy, dz);
}
/// <summary>
/// Calculates the dot product of two vectors.
/// </summary>
/// <param name="left">The source vector.</param>
/// <param name="right">The source vector.</param>
/// <returns>The dot product of the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 Dot(SFixPointVector3 left, SFixPointVector3 right)
{
return (left.m_X * right.m_X) + (left.m_Y * right.m_Y) + (left.m_Z * right.m_Z);
}
/// <summary>
/// Calculates the dot product of two vectors.
/// </summary>
/// <param name="left">The source vector.</param>
/// <param name="right">The source vector.</param>
/// <returns>The dot product of the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16Long DotLong(SFixPointVector3 left, SFixPointVector3 right)
{
return new() { m_Value = (left.m_X.m_Value * (long)right.m_X.m_Value) + (left.m_Y.m_Value * (long)right.m_Y.m_Value) + (left.m_Z.m_Value * (long)right.m_Z.m_Value) };
}
/// <summary>
/// Calculates the sign of the dot product of two vectors.
/// </summary>
/// <param name="left">The source vector.</param>
/// <param name="right">The source vector.</param>
/// <returns>The sign of the dot product of the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int DotSign(SFixPointVector3 left, SFixPointVector3 right)
{
unchecked
{
return ((left.m_X.m_Value * (long)right.m_X.m_Value) + (left.m_Y.m_Value * (long)right.m_Y.m_Value) + (left.m_Z.m_Value * (long)right.m_Z.m_Value)).CompareTo(0);
}
}
/// <summary>
/// Creates a unit vector from the specified vector.
/// </summary>
/// <param name="value">The source vector.</param>
/// <returns>The created unit vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Normalize(SFixPointVector3 value)
{
return value.Normalized;
}
/// <summary>
/// Returns a vector that contains the lowest value from each matching pair of components.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <returns>The minimized vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Min(SFixPointVector3 value1, SFixPointVector3 value2)
{
SFixPointVector3 result;
result.m_X = FixPoint16.Min(value1.m_X, value2.m_X);
result.m_Y = FixPoint16.Min(value1.m_Y, value2.m_Y);
result.m_Z = FixPoint16.Min(value1.m_Z, value2.m_Z);
return result;
}
/// <summary>
/// Returns a vector that contains the highest value from each matching pair of components.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <returns>The maximized vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Max(SFixPointVector3 value1, SFixPointVector3 value2)
{
SFixPointVector3 result;
result.m_X = FixPoint16.Max(value1.m_X, value2.m_X);
result.m_Y = FixPoint16.Max(value1.m_Y, value2.m_Y);
result.m_Z = FixPoint16.Max(value1.m_Z, value2.m_Z);
return result;
}
/// <summary>
/// Returns a vector pointing in the opposite direction.
/// </summary>
/// <param name="value">The source vector.</param>
/// <returns>A new vector pointing in the opposite direction.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Negate(SFixPointVector3 value)
{
unchecked
{
SFixPointVector3 result;
result.m_X = -value.m_X;
result.m_Y = -value.m_Y;
result.m_Z = -value.m_Z;
return result;
}
}
/// <summary>
/// Adds two vectors
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <returns>A new vector representing the sum of the source vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Add(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X + value2.m_X;
result.m_Y = value1.m_Y + value2.m_Y;
result.m_Z = value1.m_Z + value2.m_Z;
return result;
}
}
/// <summary>
/// Adds a given scalar value to each component of a given vector.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The scalar value to be added to the vector.</param>
/// <returns>A new FixPointVector3 representing the sum of the given vector and scalar.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Add(SFixPointVector3 value1, FixPoint16 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X + value2;
result.m_Y = value1.m_Y + value2;
result.m_Z = value1.m_Z + value2;
return result;
}
}
/// <summary>
/// Subtracts a vector from another vector.
/// </summary>
/// <param name="value1">The vector to be subtracted from.</param>
/// <param name="value2">The vector to be subtracted.</param>
/// <returns>A new vector representing the result of the subtraction.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Subtract(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X - value2.m_X;
result.m_Y = value1.m_Y - value2.m_Y;
result.m_Z = value1.m_Z - value2.m_Z;
return result;
}
}
/// <summary>
/// Subtracts a given scalar value from each component of a given vector.
/// </summary>
/// <param name="value1">The vector to be subtracted from.</param>
/// <param name="value2">The scalar value to subtracted.</param>
/// <returns>A new FixPointVector3 representing the result of the subtraction.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Subtract(SFixPointVector3 value1, FixPoint16 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X - value2;
result.m_Y = value1.m_Y - value2;
result.m_Z = value1.m_Z - value2;
return result;
}
}
/// <summary>
/// Multiplies the components of two vectors by each other.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <returns>A new vector representing the result of the mulitiplication.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Multiply(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X * value2.m_X;
result.m_Y = value1.m_Y * value2.m_Y;
result.m_Z = value1.m_Z * value2.m_Z;
return result;
}
}
/// <summary>
/// Multiplies a vector by a scalar value.
/// </summary>
/// <param name="value">The source vector.</param>
/// <param name="scalar">The scalar value.</param>
/// <returns>A new vector representing the result of the mulitiplication.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Multiply(SFixPointVector3 value, FixPoint16 scalar)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value.m_X * scalar;
result.m_Y = value.m_Y * scalar;
result.m_Z = value.m_Z * scalar;
return result;
}
}
/// <summary>
/// Divides the components of a vector by the components of another vector.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The divisor vector.</param>
/// <returns>A new vector representing the result of the division.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Divide(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X / value2.m_X;
result.m_Y = value1.m_Y / value2.m_Y;
result.m_Z = value1.m_Z / value2.m_Z;
return result;
}
}
/// <summary>
/// Projects a vector onto another vector.
/// </summary>
/// <param name="projected">The projected vector.</param>
/// <param name="projectionTarget">The vector the projected vector is being projected on.</param>
/// <returns>A new vector representing the result of the division.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Project(SFixPointVector3 projected, SFixPointVector3 projectionTarget)
{
unchecked
{
SFixPointVector3 result;
var fpDot = Dot(projected, projectionTarget);
var fpProjectionTargetLength = projectionTarget.Length();
var fpScalar = fpDot / (fpProjectionTargetLength * fpProjectionTargetLength);
result = projectionTarget * fpScalar;
return result;
}
}
/// <summary>
/// Divides a vector by a scalar value.
/// </summary>
/// <param name="value">The source vector.</param>
/// <param name="divider">The divider</param>
/// <returns>A new vector representing the result of the division.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Divide(SFixPointVector3 value, FixPoint16 divider)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value.m_X / divider;
result.m_Y = value.m_Y / divider;
result.m_Z = value.m_Z / divider;
return result;
}
}
/// <summary>
/// Creates a new FixPointVector3 with each component being the result of dividing a scalar value by the corresponding
/// component of a vector.
/// </summary>
/// <param name="value">The scalar value to be divided.</param>
/// <param name="divider">The divider vector</param>
/// <returns>
/// A new vector with each component being the result of dividing the scalar value by the corresponding component
/// of the vector.
/// </returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Divide(FixPoint16 value, SFixPointVector3 divider)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value / divider.m_X;
result.m_Y = value / divider.m_Y;
result.m_Z = value / divider.m_Z;
return result;
}
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <param name="amount">
/// The value between 0 and 1 indicating the weight of _value2. '0.0' will cause _value1 to be
/// returned; '1.0' will cause _value2 to be returned.
/// </param>
/// <returns>The linear interpolation of the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Lerp(SFixPointVector3 value1, SFixPointVector3 value2, FixPoint16 amount)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X + (amount * (value2.m_X - value1.m_X));
result.m_Y = value1.m_Y + (amount * (value2.m_Y - value1.m_Y));
result.m_Z = value1.m_Z + (amount * (value2.m_Z - value1.m_Z));
return result;
}
}
/// <summary>
/// Interpolates between two vectors using a cubic equation.
/// </summary>
/// <param name="value1">The source value.</param>
/// <param name="value2">The source value.</param>
/// <param name="amount">The weighting value.</param>
/// <returns>The interpolated value.</returns>
public static SFixPointVector3 SmoothStep(SFixPointVector3 value1, SFixPointVector3 value2, FixPoint16 amount)
{
var smootstep = FixPoint16.Min(0, FixPoint16.Max(1, amount));
smootstep = smootstep * smootstep * (3 - (2 * smootstep));
return Lerp(value1, value2, smootstep);
}
/// <summary>
/// Returns a FixPointVector3 containing the 2D Cartesian coordinates of a point specified in barycentric (areal)
/// coordinates relative to a 2D triangle.
/// </summary>
/// <param name="value1">A FixPointVector3 containing the 2D Cartesian coordinates of vertex 1 of the triangle.</param>
/// <param name="value2">A FixPointVector3 containing the 2D Cartesian coordinates of vertex 2 of the triangle.</param>
/// <param name="value3">A FixPointVector3 containing the 2D Cartesian coordinates of vertex 3 of the triangle.</param>
/// <param name="amount1">
/// Barycentric coordinate b2, which expresses the weighting factor toward vertex 2 (specified in
/// _value2).
/// </param>
/// <param name="amount2">
/// Barycentric coordinate b3, which expresses the weighting factor toward vertex 3 (specified in
/// _value3).
/// </param>
/// <returns>A new FixPointVector3 containing the 2D Cartesian coordinates of the specified point.</returns>
public static SFixPointVector3 Barycentric(SFixPointVector3 value1, SFixPointVector3 value2, SFixPointVector3 value3, FixPoint16 amount1, FixPoint16 amount2)
{
SFixPointVector3 result;
result.m_X = value1.m_X + (amount1 * (value2.m_X - value1.m_X)) + (amount2 * (value3.m_X - value1.m_X));
result.m_Y = value1.m_Y + (amount1 * (value2.m_Y - value1.m_Y)) + (amount2 * (value3.m_Y - value1.m_Y));
result.m_Z = value1.m_Z + (amount1 * (value2.m_Z - value1.m_Z)) + (amount2 * (value3.m_Z - value1.m_Z));
return result;
}
public static SFixPointVector3 Fract(SFixPointVector3 p)
{
return new(FixPoint16.Fract(p.m_X), FixPoint16.Fract(p.m_Y), FixPoint16.Fract(p.m_Z));
}
#endregion
#region -- operators --------------------------------------------------
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool operator ==(SFixPointVector3 left, SFixPointVector3 right)
{
return left.Equals(right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool operator !=(SFixPointVector3 left, SFixPointVector3 right)
{
return !left.Equals(right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator -(SFixPointVector3 value)
{
return Negate(value);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator +(SFixPointVector3 left, SFixPointVector3 right)
{
unchecked
{
SFixPointVector3 result;
result.m_X = left.m_X + right.m_X;
result.m_Y = left.m_Y + right.m_Y;
result.m_Z = left.m_Z + right.m_Z;
return result;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator +(SFixPointVector3 left, FixPoint16 right)
{
return Add(left, right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator -(SFixPointVector3 left, SFixPointVector3 right)
{
unchecked
{
SFixPointVector3 result;
result.m_X = left.m_X - right.m_X;
result.m_Y = left.m_Y - right.m_Y;
result.m_Z = left.m_Z - right.m_Z;
return result;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator -(SFixPointVector3 left, FixPoint16 right)
{
return Subtract(left, right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator *(SFixPointVector3 left, SFixPointVector3 right)
{
return Multiply(left, right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator *(SFixPointVector3 left, FixPoint16 right)
{
return new() {
m_X = left.m_X * right,
m_Y = left.m_Y * right,
m_Z = left.m_Z * right
};
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator *(FixPoint16 left, SFixPointVector3 right)
{
return Multiply(right, left);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator /(SFixPointVector3 left, SFixPointVector3 right)
{
return Divide(left, right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator /(SFixPointVector3 left, FixPoint16 right)
{
return new() {
m_X = left.m_X / right,
m_Y = left.m_Y / right,
m_Z = left.m_Z / right
};
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator /(FixPoint16 left, SFixPointVector3 right)
{
return Divide(left, right);
}
#endregion
/// <summary>
/// The x-component of the vector.
/// </summary>
public FixPoint16 m_X;
/// <summary>
/// The y-component of the vector.
/// </summary>
public FixPoint16 m_Y;
/// <summary>
/// The z-component of the vector.
/// </summary>
public FixPoint16 m_Z;
}

View File

@@ -1 +0,0 @@
uid://dci3uksrcv47

View File

@@ -1,119 +0,0 @@
using System;
namespace MagmaEngine.Math;
/// <summary>
/// Implements a XorShift* PRNG, with 64 bits of internal state.
/// See http://en.wikipedia.org/wiki/Xorshift
/// </summary>
public struct SIntRandom
{
public SIntRandom(ulong seed)
{
if (seed == 0)
throw new InvalidOperationException("Seed needs to be bigger than zero.");
m_Seed = seed;
}
public ulong Next()
{
m_Seed ^= m_Seed >> 12;
m_Seed ^= m_Seed << 25;
m_Seed ^= m_Seed >> 27;
return m_Seed * 2685821657736338717UL; // multiplier taken from wikipedia article on XorShift PRNGs
}
public ulong Next(ulong upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
return Next() % upperLimit;
}
public FixPoint16 NextFixPoint16()
{
return new() { m_Value = (int)(Next() & 0xffffUL) };
}
public int RandomizedRound(FixPoint16 value)
{
int ret = value.ToIntFloor();
if (NextFixPoint16() < FixPoint16.Fract(value))
ret++;
return ret;
}
public uint Next(uint upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
return (uint)(Next() % upperLimit);
}
public uint Next(uint lowerLimit, uint upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
if (lowerLimit == upperLimit)
{
return lowerLimit;
}
return lowerLimit + (uint)(Next() % (upperLimit - lowerLimit));
}
public int Next(int upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
return (int)(Next() % (uint)(upperLimit & 0x7fffffff));
}
public int Next(int lowerLimit, int upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
if (lowerLimit == upperLimit)
{
return lowerLimit;
}
return lowerLimit + (int)(Next() % (uint)((upperLimit - lowerLimit) & 0x7fffffff));
}
public double NextDouble()
{
return Next(int.MaxValue) * (1.0 / int.MaxValue);
}
public float NextSingle()
{
return (float)NextDouble();
}
public override readonly string ToString()
{
return $"0x{m_Seed:X}";
}
public readonly ulong Seed => m_Seed;
private ulong m_Seed;
}

View File

@@ -3,12 +3,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 2012
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SideScrollerGame.Godot", "godot/SideScrollerGame.Godot.csproj", "{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SideScrollerGame.Sim", "src\SideScrollerGame.Sim\SideScrollerGame.Sim.csproj", "{FCF8F1C2-B854-4F88-8106-65102689E5E3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SideScrollerGame.Sim.Tests", "tests\SideScrollerGame.Sim.Tests\SideScrollerGame.Sim.Tests.csproj", "{6F63FE1D-9388-4826-9781-63D8C0AE4155}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -49,54 +43,6 @@ Global
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Release|x64.Build.0 = Release|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Release|x86.ActiveCfg = Release|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Release|x86.Build.0 = Release|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Debug|x64.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Debug|x64.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Debug|x86.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Debug|x86.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportDebug|x64.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportDebug|x86.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportRelease|x64.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportRelease|x86.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Release|Any CPU.Build.0 = Release|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Release|x64.ActiveCfg = Release|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Release|x64.Build.0 = Release|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Release|x86.ActiveCfg = Release|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Release|x86.Build.0 = Release|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Debug|x64.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Debug|x64.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Debug|x86.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Debug|x86.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportDebug|x64.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportDebug|x86.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportRelease|x64.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportRelease|x86.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Release|Any CPU.Build.0 = Release|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Release|x64.ActiveCfg = Release|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Release|x64.Build.0 = Release|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Release|x86.ActiveCfg = Release|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -1,345 +0,0 @@
# Build the deterministic simulation foundation for the side scroller shooter
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
`PLANS.md` is checked into the repository root at `PLANS.md`. This document must be maintained in accordance with `PLANS.md`.
## Purpose / Big Picture
After this work, the repository will support a classic 2D side scroller shooter whose gameplay rules live in a pure .NET simulation instead of inside Godot scene logic. A developer will be able to run deterministic simulation tests without starting Godot, record and replay exact input streams, save and reload full simulation state, and use Godot only as the host for rendering, audio, input capture, and authoring tools. The change is visible when `dotnet test` can prove gameplay behavior, and when the Godot host can step, pause, fast-forward, and replay the same simulation state hashes for the same seed and inputs.
The user-visible outcome is not merely “new projects were added.” The outcome is that the same content, seed, and action stream produce the same gameplay results in both automated tests and the running Godot host. A novice should be able to follow this plan from an empty understanding of the repository and reach that observable result.
## Progress
- [x] (2026-04-16 08:24Z) Reviewed `PLANS.md`, the original `groundwork.md`, `SideScrollerGame.sln`, and `godot/SideScrollerGame.Godot.csproj` to anchor this ExecPlan to the current repository state.
- [x] (2026-04-16 08:32Z) Rewrote `groundwork.md` as a self-contained ExecPlan with milestones, repository orientation, exact commands, validation guidance, and living-document bookkeeping.
- [x] (2026-04-16 08:49Z) Created `src/SideScrollerGame.Sim/SideScrollerGame.Sim.csproj` and `tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj`, added both to `SideScrollerGame.sln`, and wired the Godot and test projects to reference the simulation assembly.
- [x] (2026-04-16 08:53Z) Copied the root `FixPoint/*.cs` files into `src/SideScrollerGame.Sim/FixPoint/`, updated the namespace to `SideScrollerGame.Sim.Math`, and added a smoke test proving the simulation project owns the deterministic math layer.
- [x] (2026-04-16 09:11Z) Added bootstrap compatibility math types missing from the copied `FixPoint` subset and validated the scaffold with `dotnet build`, `dotnet test`, and `.\godot.cmd --headless --quit --path .\godot --build-solutions`.
- [x] (2026-04-16 09:46Z) Implemented the first runnable simulation spine with `Simulation`, `SimulationState`, `WorldSnapshot`, `TickActionBatch`, deterministic fixed-tick movement, deterministic random advancement, and per-tick state hashes.
- [x] (2026-04-16 09:46Z) Implemented versioned save/load, replay recording, replay playback, and the `None`, `RoundTripState`, and `RoundTripAndStepClone` verification modes with deterministic unit coverage.
- [x] (2026-04-16 10:34Z) Added the first data-driven level runtime slice: world bounds clamping, hazard damage resolution, one-shot trigger activation, and serialization coverage for health plus activated triggers.
- [x] (2026-04-16 11:26Z) Added the first platformer locomotion slice: authored solid platforms, configurable platformer motion, grounded state, gravity, jump buffering, coyote-time jumps, and deterministic save/load coverage for the new runtime fields.
- [ ] Implement deterministic movement, collision, damage resolution, triggers, and the fixed tick pipeline with exhaustive simulation tests.
- [ ] Implement data-driven definitions for heroes, enemies, weapons, projectiles, pickups, modifiers, squads, encounters, and level runtime data.
- [ ] Implement Godot host adapters for input translation, fixed-step execution, interpolation, presentation mapping, sound playback, music transitions, and debug transport controls.
- [ ] Add compile-time authoring flow from Godot scenes into engine-agnostic runtime content under `content/compiled/`.
- [ ] Run the full validation set, update this plan with final outcomes, and keep the plan aligned with the implementation state at every stopping point.
## Surprises & Discoveries
- Observation: the repository has already moved the Godot project under `godot/`, so the restructuring work should preserve that host location instead of planning another move.
Evidence: `SideScrollerGame.sln` currently references `godot/SideScrollerGame.Godot.csproj`.
- Observation: the only deterministic math code in the repository today is the root `FixPoint/` directory, and there is no simulation project yet.
Evidence: `rg --files` lists `FixPoint/*.cs`, `godot/SideScrollerGame.Godot.csproj`, `SideScrollerGame.sln`, `PLANS.md`, and `groundwork.md`, but no `src/` or `tests/` projects.
- Observation: the repository root contains `godot.cmd`, so all Godot commands in this plan should call that wrapper with `--path .\godot`.
Evidence: `rg --files` lists both `godot.cmd` and `godot/project.godot`.
- Observation: copied `FixPoint` sources inherit the root files' read-only attribute, so the migration step must clear that attribute before applying namespace edits.
Evidence: `Get-Item src/SideScrollerGame.Sim/FixPoint/FixPoint16.cs` reported `Attributes : ReadOnly, Archive` immediately after the copy.
- Observation: the root `FixPoint` subset is not self-contained; it references compatibility types such as `IntVector2`, `SInt32Vector2`, `SFixPointQuaternion`, `SFixPointQuaternionTransform`, and `IntMath` that are not present in this repository snapshot.
Evidence: the first `dotnet build SideScrollerGame.sln` attempt after the copy failed with CS0246 and CS0103 errors pointing at those missing identifiers inside `FixPointVector2.cs`, `FixPointVector3.cs`, `FixPoint16.cs`, and `FixPoint16Long.cs`.
- Observation: `.\godot.cmd --headless --path .\godot --build-solutions` does not terminate on its own in this checkout, but adding `--quit` produces the expected build and exit behavior.
Evidence: the plain command timed out after five minutes, while `.\godot.cmd --headless --quit --path .\godot --build-solutions` completed successfully in roughly three seconds.
- Observation: the replay and state persistence layer can stay engine-agnostic by serializing fixed-point raw values and action documents through `System.Text.Json`; no Godot serialization hooks were needed for the initial deterministic spine.
Evidence: the simulation tests replay hashes successfully after round-tripping `ReplayRecordSerializer` and `Simulation.SaveState` payloads.
- Observation: the first meaningful gameplay slice can stay fully data-driven by modeling the level as world bounds plus axis-aligned hazards and triggers, while still preserving deterministic hashes and save/load behavior.
Evidence: the latest simulation tests clamp movement to authored bounds, apply hazard damage, and persist activated trigger ids through `Simulation.SaveState`.
- Observation: preserving the earlier free-move controls while introducing platformer locomotion is easier if platformer motion is explicit per player definition rather than implied for every actor immediately.
Evidence: the existing deterministic movement tests still exercise direct axis motion, while the new platformer tests author `UsesPlatformerMotion`, gravity, jump velocity, and support geometry without regressing the earlier coverage.
## Decision Log
- Decision: preserve the original seven-phase technical direction and translate it into milestone-based execution rather than replacing the architecture outright.
Rationale: the original groundwork already captured the correct constraints and domain boundaries; rewriting it as an ExecPlan reduces design drift while making the work executable.
Date/Author: 2026-04-16 / Codex
- Decision: migrate deterministic math into the simulation project by copying `FixPoint/*.cs` into `src/SideScrollerGame.Sim/FixPoint/` first, then retire the root copies only when safe.
Rationale: additive migration is safer and more idempotent in this Windows environment, where deletion is restricted and recovery should not depend on rollback commands.
Date/Author: 2026-04-16 / Codex
- Decision: keep all authoritative gameplay logic and gameplay definitions inside one pure project named `SideScrollerGame.Sim` until a concrete dependency problem appears.
Rationale: one simulation assembly is the clearest way to guarantee deterministic tests, shared replay behavior, and a thin Godot host.
Date/Author: 2026-04-16 / Codex
- Decision: use `godot.cmd` in all concrete steps instead of the shorthand `.\godot`.
Rationale: `godot.cmd` is the wrapper that actually exists in this checkout, so the plan must describe commands that a novice can run without guessing aliases.
Date/Author: 2026-04-16 / Codex
- Decision: keep the migration moving by introducing local compatibility shims for the missing fixed-point support types instead of blocking Milestone 1 on a broader math-library archaeology pass.
Rationale: the copied `FixPoint` subset already compiles and is enough for the upcoming deterministic simulation spine, while the shim types can be replaced later if fuller upstream math primitives become necessary.
Date/Author: 2026-04-16 / Codex
- Decision: use deterministic JSON documents for the first state and replay formats instead of building a custom binary serializer immediately.
Rationale: explicit versioned JSON documents are easy to diff, easy to round-trip in tests, and good enough for proving deterministic save/load behavior before optimization work begins.
Date/Author: 2026-04-16 / Codex
- Decision: start Milestone 4 with axis-aligned world bounds, hazards, and one-shot triggers before tackling richer collision geometry or combat systems.
Rationale: this delivers the first data-driven fixed-step gameplay rules with low implementation risk and keeps the deterministic test surface small enough to maintain 100 percent coverage.
Date/Author: 2026-04-16 / Codex
- Decision: introduce platformer locomotion as an authored capability on `PlayerDefinition` instead of replacing the existing free-move controls in one commit.
Rationale: that keeps earlier deterministic tests stable, lets the simulation support both bootstrap movement styles during the transition, and provides a controlled seam for later host integration.
Date/Author: 2026-04-16 / Codex
## Outcomes & Retrospective
The repository now has a real deterministic simulation foundation instead of only a planning note. The pure `SideScrollerGame.Sim` project owns the fixed-point math layer, deterministic stepping, save/load, replay, verification, world bounds, hazards, triggers, authored solid platforms, and the first configurable platformer locomotion rules. Fast .NET tests now prove state hashing, replay fidelity, state persistence, grounded transitions, coyote-time jumps, and jump buffering without starting Godot.
The main remaining gap is breadth, not existence. Richer collision, combat, enemies, authored content compilation, and the Godot host adapters are still unfinished, but the current code already demonstrates the intended architecture: gameplay authority in pure .NET, deterministic behavior proven by tests, and Godot positioned as the host rather than the rules engine.
## Context and Orientation
This repository is currently a small Godot 4 .NET project with one solution file, one Godot C# project, and a root folder of fixed-point math helpers. `SideScrollerGame.sln` exists at the repository root and currently includes only `godot/SideScrollerGame.Godot.csproj`. `godot/project.godot` is the Godot project entry point. `godot/SideScrollerGame.Godot.csproj` targets .NET 8 and uses `Godot.NET.Sdk/4.5.1`. The root `FixPoint/` directory contains deterministic numeric helper types such as `FixPoint16.cs`, `FixPointVector2.cs`, and `IntRandom.cs`, but those files are not yet owned by a simulation project. There is no `src/` directory, no `tests/` directory, and no pure gameplay assembly.
Several terms matter in this plan and are defined here in plain language. “Authoritative gameplay simulation” means the code that decides what is true in the game world: positions, collisions, damage, enemy behavior, triggers, checkpoints, and progression. “Deterministic” means that if the simulation receives the same content, starting state, random seed, and input actions, it must produce the same results every time, including the same debug hash. A “fixed step” is a simulation update that always advances exactly one sixtieth of a second of game time, regardless of render frame rate. A “replay” is a saved stream of input actions, plus enough metadata to rebuild the same simulation run later. “Compiled runtime content” means engine-agnostic data files produced from Godot-authored scenes so that tests and the Godot host load the same content without depending on live scene trees.
The desired end state is a repository where `src/SideScrollerGame.Sim/` contains the pure gameplay assembly, `tests/SideScrollerGame.Sim.Tests/` contains xUnit tests for engine-light gameplay behavior, `godot/` remains the presentation and authoring host, and `content/compiled/` holds deterministic runtime data produced from Godot-authored scenes. The dependency direction must be one-way: the Godot host references the simulation project, the tests reference the simulation project, and the simulation project references no Godot packages or Godot namespaces.
## Milestones
### Milestone 1: Create the simulation and test projects
This milestone establishes the repository shape needed for every later change. At the end of the milestone, the solution contains a pure gameplay project and a test project, the Godot host references the simulation assembly, and the deterministic math code lives under the simulation project path. Run the following commands from `D:\Code\SideScrollerGame` as the milestone is implemented:
dotnet new classlib --language C# --framework net8.0 --name SideScrollerGame.Sim --output src/SideScrollerGame.Sim
dotnet new xunit --language C# --framework net8.0 --name SideScrollerGame.Sim.Tests --output tests/SideScrollerGame.Sim.Tests
dotnet sln SideScrollerGame.sln add src/SideScrollerGame.Sim/SideScrollerGame.Sim.csproj
dotnet sln SideScrollerGame.sln add tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj
dotnet add godot/SideScrollerGame.Godot.csproj reference src/SideScrollerGame.Sim/SideScrollerGame.Sim.csproj
dotnet add tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj reference src/SideScrollerGame.Sim/SideScrollerGame.Sim.csproj
dotnet build SideScrollerGame.sln
Acceptance for this milestone is that the solution builds, the test project runs, and the Godot project still builds after it references the new simulation assembly. A novice should be able to inspect the solution and immediately see where pure gameplay code belongs.
### Milestone 2: Build the deterministic simulation spine
This milestone creates the smallest useful simulation loop. At the end of it, a caller can construct a `Simulation`, feed a `TickActionBatch`, advance exactly one tick, and receive a `TickResult` containing events and a deterministic hash. Run the tests and host build from `D:\Code\SideScrollerGame`:
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj
.\godot.cmd --headless --quit --path .\godot --build-solutions
Acceptance for this milestone is behavioral. Two fresh `Simulation` instances created with the same game definition, configuration, seed, and empty action stream must produce the same tick numbers, the same state hash, and the same serialized state bytes for a sequence of no-op ticks.
### Milestone 3: Add save/load, replay, and verification
This milestone makes deterministic behavior provable instead of assumed. At the end of it, the simulation can serialize all runtime state, replay saved action batches, and optionally clone itself every tick to verify that save/load and re-stepping reproduce the same state. Run this milestones proof commands from `D:\Code\SideScrollerGame`:
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj --filter Replay
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj --filter Verification
Acceptance for this milestone is that a replay generated from a test scenario reproduces the same per-tick hashes on a second run, and that `RoundTripState` plus `RoundTripAndStepClone` fail loudly when a serialization bug is introduced and pass when the state is complete.
### Milestone 4: Implement movement, collision, and the fixed tick pipeline
This milestone makes the simulation look like a game instead of a timer. At the end of it, the simulation owns movement, collision, weapon intent resolution, damage resolution, triggers, and snapshot generation in a stable per-tick order. Run the verification commands from `D:\Code\SideScrollerGame`:
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj --filter Movement
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj --filter Collision
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj --filter Trigger
Acceptance for this milestone is that deterministic tests cover grounded movement, jump buffering, coyote time, one-way platforms, hazards, projectile sweeps, and trigger ordering, and that those tests fail when system order changes incorrectly.
### Milestone 5: Implement combat systems, enemies, and coordinated behaviors
This milestone fills out the genres core gameplay. At the end of it, the simulation supports heroes, enemies, weapons, projectiles, pickups, modifiers, squads, flocking, scripted encounters, and boss phase data using engine-agnostic definitions. Run the proof commands from `D:\Code\SideScrollerGame`:
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj --filter Combat
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj --filter Behavior
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj --filter Encounter
Acceptance for this milestone is that tests can demonstrate reusable enemy behaviors, squad coordination, damage and drop resolution, and deterministic boss phase transitions without running Godot.
### Milestone 6: Compile authored levels into runtime content
This milestone connects Godot authoring to simulation-safe content. At the end of it, a designer can author level markers in Godot, run a compile step, and produce deterministic runtime data under `content/compiled/` that both tests and the host can consume. Run the milestone commands from `D:\Code\SideScrollerGame`:
.\godot.cmd --headless --quit --path .\godot --build-solutions
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj --filter Content
Acceptance for this milestone is that a level scene with spawn markers, checkpoints, trigger zones, encounter data, and music zones compiles into engine-agnostic definitions, and tests can load that compiled data without touching a live Godot scene tree.
### Milestone 7: Finish the Godot host and debugging tools
This milestone exposes the simulation to humans. At the end of it, the Godot host translates live input into `TickActionBatch` objects, runs the fixed-step simulation, interpolates between snapshots for rendering, plays overlapping sound effects and cross-faded music, and offers debug transport controls for play, pause, restart, single-step, and fast-forward. Run the proof commands from `D:\Code\SideScrollerGame`:
.\godot.cmd --editor --path .\godot
.\godot.cmd --headless --quit --path .\godot --build-solutions
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj
Acceptance for this milestone is that a developer can start the Godot project, load a test level, pause the simulation, advance one step at a time, jump forward to a later tick using saved checkpoints, and observe the same state hash sequence that the automated replay tests produce.
## Plan of Work
Start by extending the repository structure instead of altering the Godot host in place. Create `src/SideScrollerGame.Sim/SideScrollerGame.Sim.csproj` and `tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj`, then add both projects to `SideScrollerGame.sln`. Update `godot/SideScrollerGame.Godot.csproj` to reference `src/SideScrollerGame.Sim/SideScrollerGame.Sim.csproj`, and update the test project to reference the same simulation project. If common compiler settings are needed for nullable reference types or analyzers, add them through a shared file such as `Directory.Build.props` at the repository root so the Godot host, simulation project, and tests stay aligned.
Next, migrate deterministic math into the new simulation project. Copy the existing files from `FixPoint/` into `src/SideScrollerGame.Sim/FixPoint/` and change namespaces so they read as simulation infrastructure rather than host helpers. Do not make the Godot project authoritative for any gameplay behavior. If the root `FixPoint/` directory remains temporarily, ensure that only the simulation project compiles the copied files and treat the root copies as legacy bootstrap artifacts until a safe cleanup step is possible.
After the math layer is inside the simulation project, build the minimal runtime model under `src/SideScrollerGame.Sim/`. Create folders and namespaces that match the long-term architecture: `Definitions/`, `Runtime/`, `Systems/`, `Serialization/`, `Replay/`, and `Verification/`. Define immutable configuration and content types under `Definitions/`. Define mutable world state under `Runtime/`. Define the fixed tick pipeline under `Systems/`. Define deterministic serialization under `Serialization/`. Define replay records under `Replay/`. Define clone-step verification and debug hashes under `Verification/`.
Implement the first useful public API immediately instead of waiting for all systems to exist. `src/SideScrollerGame.Sim/Simulation.cs` should expose the constructor and methods described in the original groundwork: a constructor that takes `GameDefinition`, `SimulationConfig`, and a seed; `CurrentTick`; `CurrentState`; `PreviousSnapshot`; `CurrentSnapshot`; `Step(in TickActionBatch actions)`; `SaveState()`; and `LoadState(...)`. The early implementation can return empty event lists and minimal snapshots, but it must already be deterministic, serializable, and testable.
Once the simulation spine exists, implement replay and verification before building rich gameplay systems. Add explicit versioned serialization for `SimulationState` and replay files. Introduce `VerificationMode.None`, `VerificationMode.RoundTripState`, and `VerificationMode.RoundTripAndStepClone`. Write tests that intentionally prove determinism: same seed plus same actions equals same hashes, while different seeds or different actions change hashes. Keep test fixtures engine-light and executable from `dotnet test` alone.
With deterministic scaffolding proven, implement gameplay in stable layers. Add movement and collision first because every other system depends on positions and contacts. Then add damage, projectiles, pickups, and modifiers. Then add AI behaviors, squads, flocking, and scripted encounters. Keep all semantic animation and audio requests as plain simulation events, not Godot-specific clip names or audio player calls. When a new subsystem is added, add tests that prove its behavior in isolation and in scenario form.
In parallel with later gameplay systems, build the host-side adapters under `godot/scripts/host/`. Add a run controller that owns real-time accumulation and fixed-step execution. Add an input translator that converts keyboard and gamepad input into serializable simulation actions. Add presenter code that maps `EntityId` values to visual nodes and interpolates only between simulation snapshots. Add an audio layer that consumes logical sound and music requests. Add debug controls that operate on the simulation runner rather than on Godot timers or animation state.
Finally, add the authoring and content compile path. Godot scenes remain the editing surface, but the implementation must compile those scenes into deterministic runtime definitions under `content/compiled/`. Add validation tools for broken references, overlapping markers, and invalid encounters before those authored assets reach the simulation. The tests and the host must both load the compiled content so that authoring and gameplay are verified against the same data.
## Concrete Steps
Run all commands from `D:\Code\SideScrollerGame` unless a step states otherwise.
1. Create the new projects and add them to the solution.
dotnet new classlib --language C# --framework net8.0 --name SideScrollerGame.Sim --output src/SideScrollerGame.Sim
dotnet new xunit --language C# --framework net8.0 --name SideScrollerGame.Sim.Tests --output tests/SideScrollerGame.Sim.Tests
dotnet sln SideScrollerGame.sln add src/SideScrollerGame.Sim/SideScrollerGame.Sim.csproj
dotnet sln SideScrollerGame.sln add tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj
dotnet add godot/SideScrollerGame.Godot.csproj reference src/SideScrollerGame.Sim/SideScrollerGame.Sim.csproj
dotnet add tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj reference src/SideScrollerGame.Sim/SideScrollerGame.Sim.csproj
Expect `dotnet sln` to report that both projects were added, and expect the `dotnet add ... reference ...` commands to report a new project reference.
2. Copy the root `FixPoint/*.cs` files into `src/SideScrollerGame.Sim/FixPoint/`, then update the namespaces inside those files so the simulation project owns the deterministic numeric layer.
After the copy, build the solution:
dotnet build SideScrollerGame.sln
Expect build output to mention all three projects and to finish without duplicate type definitions. If duplicate types appear, the wrong project is compiling both copies of the math files.
3. Add the simulation spine and test it immediately. Create `src/SideScrollerGame.Sim/Simulation.cs`, `src/SideScrollerGame.Sim/Runtime/SimulationState.cs`, `src/SideScrollerGame.Sim/Runtime/WorldSnapshot.cs`, `src/SideScrollerGame.Sim/Input/SimulationAction.cs`, `src/SideScrollerGame.Sim/Input/TickActionBatch.cs`, `src/SideScrollerGame.Sim/Verification/VerificationMode.cs`, and matching tests under `tests/SideScrollerGame.Sim.Tests/`.
Run:
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj
Expect early tests to prove that stepping the simulation increments the tick and yields stable hashes for identical inputs.
4. Add serialization, replay, and verification. Create files under `src/SideScrollerGame.Sim/Serialization/`, `src/SideScrollerGame.Sim/Replay/`, and `src/SideScrollerGame.Sim/Verification/`, then add tests for replay determinism and state round-tripping.
Run:
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj --filter Replay
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj --filter Verification
Expect replay tests to pass with the same hash sequence on repeated runs.
5. Implement movement, collision, combat, triggers, and scenario tests in the simulation project. Keep adding tests before wiring presentation details in Godot.
Run:
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura /p:Threshold=100 /p:ThresholdType=line,branch /p:ThresholdStat=total
Expect the command to fail if gameplay logic coverage drops below 100 percent.
6. Wire the Godot host to the simulation project by adding host-side runner, presenter, audio, and debug transport code under `godot/scripts/host/`, then confirm the host still compiles.
Run:
.\godot.cmd --headless --quit --path .\godot --build-solutions
Expect Godot to regenerate and build C# project artifacts successfully.
7. Build the content compile path and manual host verification flow.
Run:
.\godot.cmd --editor --path .\godot
In the editor, validate that authored level scenes can be compiled into `content/compiled/`, then run the game and observe that pausing, single-stepping, and replaying preserve deterministic state hashes.
## Validation and Acceptance
The implementation is acceptable only when a human can see deterministic behavior, not merely when the code compiles. Start with automated proof. `dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj` must pass, and replay-oriented tests must demonstrate that the same seed and action stream yield the same per-tick debug hash sequence. The coverage command in `Concrete Steps` must enforce 100 percent line and branch coverage for gameplay logic inside `SideScrollerGame.Sim`, with only narrow exclusions for non-logic infrastructure such as generated serializers or lookup tables.
Then verify the host integration. `.\godot.cmd --headless --quit --path .\godot --build-solutions` must succeed, proving that the Godot project can consume the simulation assembly. After that, start the editor with `.\godot.cmd --editor --path .\godot`, load a level built from compiled runtime content, and verify the following behaviors manually: play starts the simulation, pause freezes the simulation while leaving the editor responsive, advance-one-step increments the tick exactly once, fast-forward resumes from the nearest saved checkpoint and reaches the requested tick, and replaying the same recorded input reproduces the same state hash display.
The final acceptance scenario is simple and observable. Record a short run in which the player moves, fires, triggers an encounter, collects a pickup, and reaches a checkpoint. Save the replay. Restart the host with the same compiled content and seed, play the replay, and confirm that the tick count, key gameplay events, and final state hash all match the original run. If any of those differ, the implementation is incomplete.
## Idempotence and Recovery
Most steps in this plan are additive and safe to repeat. Re-running `dotnet build`, `dotnet test`, or `.\godot.cmd --headless --quit --path .\godot --build-solutions` is safe and should produce the same result when the repository state has not changed. Re-running the host replay validation is also safe because replays and checkpoints are meant to be reproducible.
Project creation commands such as `dotnet new classlib` and `dotnet new xunit` are not naturally idempotent once the destination directories already exist. If those directories have already been created, do not delete them to start over. Instead, inspect the generated files, patch them into the required shape, and continue. The same rule applies to content compilation in this Windows environment: prefer overwriting generated runtime content in place instead of deleting directories.
When migrating the `FixPoint/` sources, do not remove the root copies until the simulation project is compiling and all references are proven correct. If a migration step goes wrong, recover by fixing project includes and namespaces rather than by using rollback commands such as `git restore` or `git reset`. The plan is intentionally written so that each milestone can be resumed from the current working tree without assuming a clean slate.
## Artifacts and Notes
Current repository snapshot, captured while writing this plan:
PS D:\Code\SideScrollerGame> rg --files
SideScrollerGame.sln
PLANS.md
groundwork.md
godot.cmd
godot\SideScrollerGame.Godot.csproj
godot\project.godot
FixPoint\IntRandom.cs
FixPoint\FixPoint16.cs
...
Current solution shape, which this plan will extend:
Project(...) = "SideScrollerGame.Godot", "godot/SideScrollerGame.Godot.csproj", "{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}"
Current Godot project target framework:
<Project Sdk="Godot.NET.Sdk/4.5.1">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
Use those facts as the starting point when implementing this plan. If the repository changes later, update this section and every other affected section of the plan so the document remains self-contained.
## Interfaces and Dependencies
Use .NET 8 for the simulation and test projects so they align with the existing Godot project target. Keep the Godot host on `Godot.NET.Sdk/4.5.1` unless a later, repository-wide upgrade is performed deliberately and documented in this plan. The simulation project may use built-in .NET libraries and deterministic-support libraries that do not pull in Godot. Avoid floating-point math, wall-clock time, thread scheduling, asynchronous gameplay work, or any API that can vary results between runs.
At the end of Milestone 2, the simulation project must define these public interfaces and types with stable names and repository-relative locations:
In `src/SideScrollerGame.Sim/Simulation.cs`, define:
public sealed class Simulation
{
public Simulation(GameDefinition gameDefinition, SimulationConfig config, int seed);
public int CurrentTick { get; }
public SimulationState CurrentState { get; }
public WorldSnapshot PreviousSnapshot { get; }
public WorldSnapshot CurrentSnapshot { get; }
public TickResult Step(in TickActionBatch actions);
public byte[] SaveState();
public static Simulation LoadState(byte[] data, GameDefinition gameDefinition, SimulationConfig config);
}
In `src/SideScrollerGame.Sim/Input/SimulationAction.cs`, define the serializable action hierarchy:
public abstract record SimulationAction;
public sealed record MoveAxisChanged(PlayerId PlayerId, sbyte X, sbyte Y) : SimulationAction;
public sealed record AimAxisChanged(PlayerId PlayerId, short X, short Y) : SimulationAction;
public sealed record ButtonChanged(PlayerId PlayerId, InputButton Button, bool IsPressed) : SimulationAction;
public sealed record WeaponSlotSelected(PlayerId PlayerId, int SlotIndex) : SimulationAction;
In `src/SideScrollerGame.Sim/Input/TickActionBatch.cs`, define:
public sealed record TickActionBatch(int Tick, ImmutableArray<SimulationAction> Actions);
In `src/SideScrollerGame.Sim/Verification/VerificationMode.cs`, define:
public enum VerificationMode
{
None,
RoundTripState,
RoundTripAndStepClone
}
In `src/SideScrollerGame.Sim/Runtime/`, define `SimulationState`, `WorldSnapshot`, `TickResult`, entity identifiers, runtime state groups such as `PlayerState`, `EnemyState`, `ProjectileState`, `PickupState`, `HazardState`, `PlatformState`, `TriggerState`, `CameraState`, `LevelRuntimeState`, and `SquadState`, and a stable per-tick pipeline that applies actions, advances timers, updates AI, resolves weapons, integrates movement, resolves collisions, applies damage, processes triggers, and then produces events, snapshots, and hashes.
In `src/SideScrollerGame.Sim/Definitions/`, define immutable content types including `GameDefinition`, `LevelDefinition`, `HeroDefinition`, `EnemyDefinition`, `WeaponDefinition`, `ProjectileDefinition`, `PowerUpDefinition`, `SquadDefinition`, and related modifier and behavior definitions. These types must remain engine-agnostic and serializable.
In `src/SideScrollerGame.Sim/Replay/` and `src/SideScrollerGame.Sim/Serialization/`, define versioned replay and state persistence types that can serialize the full simulation state, recorded action batches, content hash, seed, and optional checkpoints without relying on Godot serialization.
In `godot/scripts/host/`, define host-side adapters with clear responsibilities: a `RunController` that owns real-time accumulation and fixed-step stepping, an input translator that turns Godot input into simulation actions, a presentation registry keyed by `EntityId`, interpolation code that renders between `PreviousSnapshot` and `CurrentSnapshot`, and audio services that consume simulation sound and music requests without becoming gameplay authorities.
Revision Note: 2026-04-16, Codex. Replaced the original groundwork note with an ExecPlan that follows `PLANS.md`, because the old document captured architectural intent but did not give a novice enough concrete guidance to implement, validate, and maintain the work.

View File

@@ -1,28 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record AxisAlignedBounds
{
public AxisAlignedBounds(FixPointVector2 min, FixPointVector2 max)
{
Min = min;
Max = max;
}
public bool Contains(FixPointVector2 position)
{
return position.m_X >= Min.m_X && position.m_X <= Max.m_X && position.m_Y >= Min.m_Y && position.m_Y <= Max.m_Y;
}
public FixPointVector2 Clamp(FixPointVector2 position)
{
return new(FixPoint16.Clamp(position.m_X, Min.m_X, Max.m_X), FixPoint16.Clamp(position.m_Y, Min.m_Y, Max.m_Y));
}
public FixPointVector2 Min { get; init; }
public FixPointVector2 Max { get; init; }
}

View File

@@ -1,18 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record GameDefinition
{
public GameDefinition(LevelDefinition level, ImmutableArray<PlayerDefinition> players)
{
Level = level;
Players = players.IsDefault ? ImmutableArray<PlayerDefinition>.Empty : players;
}
public LevelDefinition Level { get; init; }
public ImmutableArray<PlayerDefinition> Players { get; init; }
}

View File

@@ -1,20 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record HazardDefinition
{
public HazardDefinition(string id, AxisAlignedBounds bounds, int damagePerTick)
{
Id = id;
Bounds = bounds;
DamagePerTick = damagePerTick;
}
public string Id { get; init; }
public AxisAlignedBounds Bounds { get; init; }
public int DamagePerTick { get; init; }
}

View File

@@ -1,24 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record LevelDefinition
{
public LevelDefinition(AxisAlignedBounds worldBounds, ImmutableArray<HazardDefinition> hazards, ImmutableArray<TriggerDefinition> triggers, ImmutableArray<SolidPlatformDefinition> platforms)
{
WorldBounds = worldBounds;
Hazards = hazards.IsDefault ? ImmutableArray<HazardDefinition>.Empty : hazards;
Triggers = triggers.IsDefault ? ImmutableArray<TriggerDefinition>.Empty : triggers;
Platforms = platforms.IsDefault ? ImmutableArray<SolidPlatformDefinition>.Empty : platforms;
}
public AxisAlignedBounds WorldBounds { get; init; }
public ImmutableArray<HazardDefinition> Hazards { get; init; }
public ImmutableArray<TriggerDefinition> Triggers { get; init; }
public ImmutableArray<SolidPlatformDefinition> Platforms { get; init; }
}

View File

@@ -1,39 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record PlayerDefinition
{
public PlayerDefinition(PlayerId playerId, FixPointVector2 spawnPosition, int maxHealth, bool usesPlatformerMotion = false, FixPoint16 moveSpeedPerTick = default, FixPoint16 gravityPerTick = default, FixPoint16 jumpVelocityPerTick = default, int coyoteTicks = 0, int jumpBufferTicks = 0)
{
PlayerId = playerId;
SpawnPosition = spawnPosition;
MaxHealth = maxHealth;
UsesPlatformerMotion = usesPlatformerMotion;
MoveSpeedPerTick = moveSpeedPerTick;
GravityPerTick = gravityPerTick;
JumpVelocityPerTick = jumpVelocityPerTick;
CoyoteTicks = coyoteTicks;
JumpBufferTicks = jumpBufferTicks;
}
public PlayerId PlayerId { get; init; }
public FixPointVector2 SpawnPosition { get; init; }
public int MaxHealth { get; init; }
public bool UsesPlatformerMotion { get; init; }
public FixPoint16 MoveSpeedPerTick { get; init; }
public FixPoint16 GravityPerTick { get; init; }
public FixPoint16 JumpVelocityPerTick { get; init; }
public int CoyoteTicks { get; init; }
public int JumpBufferTicks { get; init; }
}

View File

@@ -1,17 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record SolidPlatformDefinition
{
public SolidPlatformDefinition(string id, AxisAlignedBounds bounds)
{
Id = id;
Bounds = bounds;
}
public string Id { get; init; }
public AxisAlignedBounds Bounds { get; init; }
}

View File

@@ -1,20 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record TriggerDefinition
{
public TriggerDefinition(string id, AxisAlignedBounds bounds, string kind)
{
Id = id;
Bounds = bounds;
Kind = kind;
}
public string Id { get; init; }
public AxisAlignedBounds Bounds { get; init; }
public string Kind { get; init; }
}

View File

@@ -1,9 +0,0 @@
namespace SideScrollerGame.Sim.Input;
public enum InputButton
{
Jump = 0,
FirePrimary = 1,
FireSecondary = 2,
Dash = 3
}

View File

@@ -1,18 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Input;
[ExcludeFromCodeCoverage]
public abstract record SimulationAction;
[ExcludeFromCodeCoverage]
public sealed record MoveAxisChanged(PlayerId PlayerId, sbyte X, sbyte Y) : SimulationAction;
[ExcludeFromCodeCoverage]
public sealed record AimAxisChanged(PlayerId PlayerId, short X, short Y) : SimulationAction;
[ExcludeFromCodeCoverage]
public sealed record ButtonChanged(PlayerId PlayerId, InputButton Button, bool IsPressed) : SimulationAction;
[ExcludeFromCodeCoverage]
public sealed record WeaponSlotSelected(PlayerId PlayerId, int SlotIndex) : SimulationAction;

View File

@@ -1,23 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Input;
[ExcludeFromCodeCoverage]
public sealed record TickActionBatch
{
public TickActionBatch(int tick, ImmutableArray<SimulationAction> actions)
{
Tick = tick;
Actions = actions.IsDefault ? ImmutableArray<SimulationAction>.Empty : actions;
}
public static TickActionBatch Empty(int tick)
{
return new(tick, ImmutableArray<SimulationAction>.Empty);
}
public int Tick { get; init; }
public ImmutableArray<SimulationAction> Actions { get; init; }
}

View File

@@ -1,62 +0,0 @@
namespace SideScrollerGame.Sim.Math;
public readonly struct IntVector2
{
public IntVector2(int x, int y)
{
X = x;
Y = y;
}
public int X { get; }
public int Y { get; }
}
public readonly struct SInt32Vector2
{
public SInt32Vector2(int x, int y)
{
X = x;
Y = y;
}
public int X { get; }
public int Y { get; }
}
public readonly struct SFixPointQuaternion
{
public SFixPointQuaternion(FixPoint16 x, FixPoint16 y, FixPoint16 z, FixPoint16 w)
{
m_X = x;
m_Y = y;
m_Z = z;
m_W = w;
}
public FixPoint16 m_X { get; }
public FixPoint16 m_Y { get; }
public FixPoint16 m_Z { get; }
public FixPoint16 m_W { get; }
}
public readonly struct SFixPointQuaternionTransform
{
public SFixPointQuaternionTransform(SFixPointVector3 position, SFixPointQuaternion orientation, SFixPointVector3 size)
{
m_Position = position;
m_Orientation = orientation;
m_Size = size;
}
public SFixPointVector3 m_Position { get; }
public SFixPointQuaternion m_Orientation { get; }
public SFixPointVector3 m_Size { get; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,349 +0,0 @@
#if DEBUG
#define RANGE_CHECK
#endif
using System.Runtime.CompilerServices;
namespace SideScrollerGame.Sim.Math;
public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquatable<FixPoint16Long>
{
public FixPoint16Long(FixPoint16Long other)
{
m_Value = other.m_Value;
}
public FixPoint16Long(int value)
{
m_Value = (long)value << c_Shift;
}
public FixPoint16Long(long value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
throw new ArithmeticException($"Long to FixPoint argument out of range: {value}");
#endif
m_Value = value << c_Shift;
}
public FixPoint16Long(double value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
throw new ArithmeticException($"Double to FixPoint argument out of range: {value}");
#endif
if (value < 0.0)
m_Value = (int)(value * c_Multiplier - 0.5);
else
m_Value = (int)(value * c_Multiplier + 0.5);
}
public FixPoint16Long(float value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
throw new ArithmeticException($"Single to FixPoint argument out of range: {value}");
#endif
if (value < 0.0f)
m_Value = (int)(value * c_MultiplierFloat - 0.5f);
else
m_Value = (int)(value * c_MultiplierFloat + 0.5f);
}
public long ToLongFloor()
{
return m_Value >> c_Shift;
}
public long ToLongCeil()
{
return (m_Value + c_FractionMask) >> c_Shift;
}
public long ToLongRound()
{
if (m_Value < 0)
return -((-m_Value + c_Half) >> c_Shift);
return (m_Value + c_Half) >> c_Shift;
}
public long ToLong()
{
if (m_Value < 0)
return -(-m_Value >> c_Shift);
return m_Value >> c_Shift;
}
public double ToDouble()
{
return c_Divisor * m_Value;
}
public float ToFloat()
{
return c_DivisorFloat * m_Value;
}
public override string ToString()
{
return $"{ToDouble()}[0x{m_Value:x16}]";
}
public override int GetHashCode()
{
return m_Value.GetHashCode();
}
public int CompareTo(object? obj)
{
if (obj is not FixPoint16Long other)
return -1;
return m_Value.CompareTo(other.m_Value);
}
public int CompareTo(FixPoint16Long other)
{
return m_Value.CompareTo(other.m_Value);
}
public override bool Equals(object? obj)
{
if (obj == null)
return false;
return ((FixPoint16Long)obj).m_Value == m_Value;
}
public bool Equals(FixPoint16Long other)
{
return other.m_Value == m_Value;
}
public bool IsZero()
{
return m_Value == 0L;
}
public static bool operator ==(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value == b.m_Value;
}
public static bool operator !=(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value != b.m_Value;
}
public static bool operator <(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value < b.m_Value;
}
public static bool operator >(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value > b.m_Value;
}
public static bool operator <=(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value <= b.m_Value;
}
public static bool operator >=(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value >= b.m_Value;
}
public static FixPoint16Long operator <<(FixPoint16Long a, int shift)
{
return new() { m_Value = a.m_Value << shift };
}
public static FixPoint16Long operator >> (FixPoint16Long a, int shift)
{
return new() { m_Value = a.m_Value >> shift };
}
public static FixPoint16Long operator +(FixPoint16Long a, FixPoint16Long b)
{
return new() { m_Value = a.m_Value + b.m_Value };
}
public static FixPoint16Long operator -(FixPoint16Long a, FixPoint16Long b)
{
return new() { m_Value = a.m_Value - b.m_Value };
}
public static FixPoint16Long operator *(FixPoint16Long a, FixPoint16Long b)
{
Int128 bigA = a.m_Value;
Int128 bigB = b.m_Value;
var result = (bigA * bigB + c_Half) >> c_Shift;
#if RANGE_CHECK
if (result < long.MinValue || result > long.MaxValue)
throw new ArithmeticException($"Multiplication result out of range: {result}");
#endif
return new() { m_Value = (long)result };
}
public static FixPoint16Long operator /(FixPoint16Long a, FixPoint16Long b)
{
#if RANGE_CHECK
if (b.m_Value == 0)
throw new ArithmeticException("Divison by zero");
#endif
Int128 result;
if (((ulong)a.m_Value & 0x8000000000000000UL) == ((ulong)b.m_Value & 0x8000000000000000UL))
result = (((Int128)a.m_Value << c_Shift) + b.m_Value / 2) / b.m_Value;
else
result = (((Int128)a.m_Value << c_Shift) - b.m_Value / 2) / b.m_Value;
#if RANGE_CHECK
if (result < long.MinValue || result > long.MaxValue)
throw new ArithmeticException($"Division result out of range: {result}");
#endif
return new() { m_Value = (long)result };
}
public static FixPoint16Long operator -(FixPoint16Long a)
{
return new() { m_Value = -a.m_Value };
}
public static FixPoint16Long operator *(FixPoint16Long a, int value)
{
return new() { m_Value = a.m_Value * value };
}
public static FixPoint16Long operator /(FixPoint16Long a, int value)
{
#if RANGE_CHECK
if (value == 0)
throw new ArithmeticException("Divison by zero");
#endif
if (((a.m_Value >> 32) & 0x80000000) == (value & 0x80000000))
return new() { m_Value = (long)(((Int128)a.m_Value + value / 2) / value) };
return new() { m_Value = (long)(((Int128)a.m_Value - value / 2) / value) };
}
public static implicit operator FixPoint16Long(int value)
{
return new(value);
}
public static implicit operator FixPoint16Long(long value)
{
return new(value);
}
public static implicit operator FixPoint16Long(FixPoint16 value)
{
return new() { m_Value = value.m_Value };
}
public static explicit operator FixPoint16Long(double value)
{
return new(value);
}
public static explicit operator FixPoint16Long(float value)
{
return new(value);
}
public static explicit operator int(FixPoint16Long value)
{
return (int)value.ToLong();
}
public static explicit operator long(FixPoint16Long value)
{
return value.ToLong();
}
public static explicit operator double(FixPoint16Long value)
{
return value.ToDouble();
}
public static explicit operator float(FixPoint16Long value)
{
return value.ToFloat();
}
public static FixPoint16Long Floor(FixPoint16Long value)
{
return value.ToLongFloor();
}
public static FixPoint16Long Ceil(FixPoint16Long value)
{
return value.ToLongCeil();
}
public static FixPoint16Long Round(FixPoint16Long value)
{
return value.ToLongRound();
}
public static int Sign(FixPoint16Long value)
{
return System.Math.Sign(value.m_Value);
}
public static FixPoint16Long Abs(FixPoint16Long value)
{
return new() { m_Value = System.Math.Abs(value.m_Value) };
}
public static FixPoint16Long Min(FixPoint16Long value1, FixPoint16Long value2)
{
return new() { m_Value = System.Math.Min(value1.m_Value, value2.m_Value) };
}
public static FixPoint16Long Max(FixPoint16Long value1, FixPoint16Long value2)
{
return new() { m_Value = System.Math.Max(value1.m_Value, value2.m_Value) };
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16Long Length(FixPoint16Long a, FixPoint16Long b)
{
var aSquared = a.m_Value * a.m_Value;
var bSquared = b.m_Value * b.m_Value;
var value = aSquared + bSquared;
#if RANGE_CHECK
if (value < 0)
throw new ArithmeticException($"Length squared out of range: {value}");
#endif
return new() { m_Value = IntMath.Sqrt(value) };
}
private const int c_Shift = 16;
private const long c_IntegerMin = -140737488355327L;
private const long c_IntegerMax = 140737488355327L;
private const long c_Half = 32786L;
private const long c_FractionMask = 0x000000000000ffffL;
private const double c_Multiplier = 65536.0;
private const double c_Divisor = 1.0 / 65536.0;
private const float c_MultiplierFloat = 65536.0f;
private const float c_DivisorFloat = (float)c_Divisor;
public long m_Value;
public static readonly FixPoint16Long Zero = new() { m_Value = 0 };
public static readonly FixPoint16Long MinValue = new() { m_Value = long.MinValue };
public static readonly FixPoint16Long MaxValue = new() { m_Value = long.MaxValue };
public static readonly FixPoint16Long Epsilon = new() { m_Value = 1 };
public static readonly FixPoint16Long MinusEpsilon = new() { m_Value = -1 };
public static readonly FixPoint16Long One = new(1);
public static readonly FixPoint16Long MinusOne = new(-1);
public static readonly FixPoint16Long Half = new() { m_Value = One.m_Value / 2 };
public static readonly FixPoint16Long MinusHalf = -Half;
}

File diff suppressed because one or more lines are too long

View File

@@ -1,69 +0,0 @@
namespace SideScrollerGame.Sim.Math;
public static class FixPointUtil
{
public static long DistancePointToSegmentSquared(FixPointVector2 point, FixPointVector2 segmentStart, FixPointVector2 segmentEnd)
{
var segment = segmentEnd - segmentStart;
var toStart = point - segmentStart;
var segmentLengthSquared = FixPointVector2.DotLong(segment, segment);
if (segmentLengthSquared == 0L)
return FixPointVector2.DotLong(toStart, toStart);
var projection = FixPointVector2.DotLong(toStart, segment);
if (projection <= 0)
return FixPointVector2.DotLong(toStart, toStart);
FixPointVector2 toPointOnSegment;
if (projection >= segmentLengthSquared)
toPointOnSegment = point - segmentEnd;
else
toPointOnSegment = toStart - segment * FixPoint16.FromRational(projection, segmentLengthSquared);
return FixPointVector2.DotLong(toPointOnSegment, toPointOnSegment);
}
public static FixPoint16 DistancePointToSegment(FixPointVector2 point, FixPointVector2 segmentStart, FixPointVector2 segmentEnd)
{
return FixPoint16.SqrtLong(DistancePointToSegmentSquared(point, segmentStart, segmentEnd));
}
public static bool LineLineIntersection(FixPointVector2 line1Start, FixPointVector2 line1End, FixPointVector2 line2Start, FixPointVector2 line2End, out FixPointVector2 intersectionPoint)
{
intersectionPoint = default;
var s1 = line1End - line1Start;
var s2 = line2End - line2Start;
var det = (FixPoint16Long)(-s2.m_X) * s1.m_Y + (FixPoint16Long)(s1.m_X) * s2.m_Y;
if (FixPoint16Long.Abs(det) < s_IntersectionEpsilon)
return false;
var t = ((FixPoint16Long)s2.m_X * (line1Start.m_Y - line2Start.m_Y) - (FixPoint16Long)s2.m_Y * (line1Start.m_X - line2Start.m_X)) / det;
var px = line1Start.m_X + (t * s1.m_X);
var py = line1Start.m_Y + (t * s1.m_Y);
if (px.m_Value > s_LineIntersectionMax || px.m_Value < s_LineIntersectionMin || py.m_Value > s_LineIntersectionMax || py.m_Value < s_LineIntersectionMin)
return false;
intersectionPoint = new FixPointVector2(FixPoint16.FromValue(px.m_Value), FixPoint16.FromValue(py.m_Value));
return true;
}
public static FixPoint16 MultiplyClamped(FixPoint16 a, FixPoint16 b)
{
var iResult = (((long)a.m_Value * b.m_Value) + FixPoint16.c_Half) >> FixPoint16.c_Shift;
if (iResult < FixPoint16.c_LongMin)
iResult = FixPoint16.c_LongMin;
else if (iResult > FixPoint16.c_LongMax)
iResult = FixPoint16.c_LongMax;
return new() { m_Value = (int)iResult };
}
private static readonly FixPoint16 s_IntersectionEpsilon = FixPoint16.Epsilon * 30;
private static readonly long s_LineIntersectionMax = FixPoint16.c_LongMax / 4;
private static readonly long s_LineIntersectionMin = FixPoint16.c_LongMin / 4;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,995 +0,0 @@
using System;
using System.Diagnostics.Contracts;
using System.Numerics;
using System.Runtime.CompilerServices;
namespace SideScrollerGame.Sim.Math;
public struct SFixPointVector3 : IEquatable<SFixPointVector3>
{
/// <summary>
/// Initializes a new instance of FixPointVector3
/// </summary>
/// <param name="x">Initial value for the x-component of the vector.</param>
/// <param name="y">Initial value for the y-component of the vector.</param>
/// <param name="z">Initial value for the z-component of the vector.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public SFixPointVector3(int x, int y, int z)
{
m_X = x;
m_Y = y;
m_Z = z;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public SFixPointVector3(FixPoint16 x, FixPoint16 y, FixPoint16 z)
{
m_X = x;
m_Y = y;
m_Z = z;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public SFixPointVector3(FixPoint16 x)
{
m_X = x;
m_Y = x;
m_Z = x;
}
public SFixPointVector3(float x, float y, float z)
{
m_X = new(x);
m_Y = new(y);
m_Z = new(z);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public SFixPointVector3(Vector3 coord)
{
m_X = new(coord.X);
m_Y = new(coord.Y);
m_Z = new(coord.Z);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static explicit operator SFixPointVector3(Vector3 coord)
{
return new(new(coord.X), new(coord.Y), new FixPoint16(coord.Z));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static explicit operator Vector3(SFixPointVector3 coord)
{
return new(coord.m_X.ToFloat(), coord.m_Y.ToFloat(), coord.m_Z.ToFloat());
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public Vector3 ToVector3()
{
return new(m_X.ToFloat(), m_Y.ToFloat(), m_Z.ToFloat());
}
#region -- base overrides ---------------------------------------------
[Pure]
public override string ToString()
{
return $"({m_X.ToString()}, {m_Y.ToString()}, {m_Z.ToString()})";
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public bool Equals(SFixPointVector3 other)
{
return m_X == other.m_X && m_Y == other.m_Y && m_Z == other.m_Z;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public override bool Equals(object? obj)
{
if (obj is SFixPointVector3 fixPointVector3)
return Equals(fixPointVector3);
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public override int GetHashCode()
{
return m_X.GetHashCode() + m_Y.GetHashCode() + m_Z.GetHashCode();
}
#endregion
#region -- static properties ------------------------------------------
public static readonly SFixPointVector3 s_Zero = new(0, 0, 0);
public static readonly SFixPointVector3 s_One = new(1, 1, 1);
public static readonly SFixPointVector3 s_UnitX = new(1, 0, 0);
public static readonly SFixPointVector3 s_UnitY = new(0, 1, 0);
public static readonly SFixPointVector3 s_UnitZ = new(0, 0, 1);
public static readonly SFixPointVector3 s_MaxValue = new(FixPoint16.MaxValue, FixPoint16.MaxValue, FixPoint16.MaxValue);
public static readonly SFixPointVector3 s_MinValue = new(FixPoint16.MinValue, FixPoint16.MinValue, FixPoint16.MinValue);
#endregion
#region -- public properties -----------------------------------------
public FixPoint16 this[int i]
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get
{
switch (i)
{
case 0:
return m_X;
case 1:
return m_Y;
case 2:
return m_Z;
default:
throw new ArgumentOutOfRangeException(nameof(i));
}
}
set
{
switch (i)
{
case 0:
m_X = value;
break;
case 1:
m_Y = value;
break;
case 2:
m_Z = value;
break;
default:
throw new ArgumentOutOfRangeException(nameof(i));
}
}
}
/// <summary>
/// Returns a new normalized FixPointVector3 from the current vector.
/// </summary>
public SFixPointVector3 Normalized
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get
{
var length = Length();
if (!length.IsZero())
{
SFixPointVector3 result = new(m_X / length, m_Y / length, m_Z / length);
return result;
}
throw new InvalidOperationException("Error: can not normalize vector, the vector length is zero.");
}
}
public FixPointVector2 XY
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get => new(m_X, m_Y);
}
public FixPointVector2 XZ
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get => new(m_X, m_Z);
}
#endregion
#region -- public methods ---------------------------------------------
/// <summary>
/// Calculates the length of the current vector.
/// </summary>
/// <returns>The Length of the current vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public FixPoint16 Length()
{
return FixPoint16.Length(m_X, m_Y, m_Z);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public int CompareLength(FixPoint16 length)
{
unchecked
{
return ((m_X.m_Value * (long)m_X.m_Value) + (m_Y.m_Value * (long)m_Y.m_Value) + (m_Z.m_Value * (long)m_Z.m_Value)).CompareTo(length.m_Value * (long)length.m_Value);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public long LengthSquaredLong()
{
unchecked
{
return (m_X.m_Value * (long)m_X.m_Value) + (m_Y.m_Value * (long)m_Y.m_Value) + (m_Z.m_Value * (long)m_Z.m_Value);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public int CompareLength(SFixPointVector3 other)
{
unchecked
{
return ((m_X.m_Value * (long)m_X.m_Value) + (m_Y.m_Value * (long)m_Y.m_Value) + (m_Z.m_Value * (long)m_Z.m_Value)).CompareTo((other.m_X.m_Value * (long)other.m_X.m_Value) + (other.m_Y.m_Value * (long)other.m_Y.m_Value) + (other.m_Z.m_Value * (long)other.m_Z.m_Value));
}
}
/// <summary>
/// Normalizes the current vector
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Normalize()
{
unchecked
{
var length = Length();
if (!length.IsZero())
{
m_X = m_X / length;
m_Y = m_Y / length;
m_Z = m_Z / length;
}
#if DEBUG
else
{
throw new("Error: can not normalize vector, the vector length is zero.");
}
#endif
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public bool IsZero()
{
return m_X.IsZero() && m_Y.IsZero() && m_Z.IsZero();
}
/// <summary>
/// Adds a given scalar value to each component of the current FixPointVector3.
/// </summary>
/// <param name="value">The scalar value</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Add(FixPoint16 value)
{
unchecked
{
m_X = m_X + value;
m_Y = m_Y + value;
m_Z = m_Z + value;
}
}
/// <summary>
/// Adds a given FixPointVector3 to the current FixPointVector3.
/// </summary>
/// <param name="other">The vector to be added.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Add(SFixPointVector3 other)
{
unchecked
{
m_X = m_X + other.m_X;
m_Y = m_Y + other.m_Y;
m_Z = m_Z + other.m_Z;
}
}
/// <summary>
/// Subtracts a scalar value from each component of the current FixPointVector3.
/// </summary>
/// <param name="value">The scalar value.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Subtract(FixPoint16 value)
{
unchecked
{
m_X = m_X - value;
m_Y = m_Y - value;
m_Z = m_Z - value;
}
}
/// <summary>
/// Subtracts a given FixPointVector3 from the current FixPointVector3.
/// </summary>
/// <param name="other">The vector to be subtracted.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Subtract(SFixPointVector3 other)
{
unchecked
{
m_X = m_X - other.m_X;
m_Y = m_Y - other.m_Y;
m_Z = m_Z - other.m_Z;
}
}
/// <summary>
/// Multiplies each component of the current FixPointVector3 by a given scalar value.
/// </summary>
/// <param name="scalar">The scalar value.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Multiply(FixPoint16 scalar)
{
unchecked
{
m_X = m_X * scalar;
m_Y = m_Y * scalar;
m_Z = m_Z * scalar;
}
}
/// <summary>
/// Multiplies the current FixPointVector3 by another FixPointVector3.
/// </summary>
/// <param name="other">The source vector.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Multiply(SFixPointVector3 other)
{
unchecked
{
m_X = m_X * other.m_X;
m_Y = m_Y * other.m_Y;
m_Z = m_Z * other.m_Z;
}
}
/// <summary>
/// Divides each component of the current FixPointVector3 by a given scalar value.
/// </summary>
/// <param name="divider">The scalar divider.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Divide(FixPoint16 divider)
{
#if DEBUG
if (divider.IsZero())
throw new("Error: divider is zero (FixPointVector3.Divide).");
#endif
unchecked
{
m_X = m_X / divider;
m_Y = m_Y / divider;
m_Z = m_Z / divider;
}
}
/// <summary>
/// Divides the current FixPointVector3 by another FixPointVector3.
/// </summary>
/// <param name="other">The vector divider.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Divide(SFixPointVector3 other)
{
unchecked
{
if (!other.m_X.IsZero() && !other.m_Y.IsZero() && !other.m_Z.IsZero())
{
m_X = m_X / other.m_X;
m_Y = m_Y / other.m_Y;
m_Z = m_Z / other.m_Z;
}
#if DEBUG
else
{
throw new("Error: divider vector contains zero (FixPointVector3.Divide).");
}
#endif
}
}
/// <summary>
/// Transforms a FixPointVector3.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public static SFixPointVector3 Transform(SFixPointVector3 v, SFixPointQuaternionTransform t)
{
return Transform(v * t.m_Size, t.m_Orientation) + t.m_Position;
}
/// <summary>
/// Transforms a vector by the given Quaternion rotation value.
/// </summary>
/// <param name="value">The source vector to be rotated.</param>
/// <param name="rotation">The rotation to apply.</param>
/// <returns>The transformed vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Transform(SFixPointVector3 value, SFixPointQuaternion rotation)
{
var x2 = rotation.m_X + rotation.m_X;
var y2 = rotation.m_Y + rotation.m_Y;
var z2 = rotation.m_Z + rotation.m_Z;
var wx2 = rotation.m_W * x2;
var wy2 = rotation.m_W * y2;
var wz2 = rotation.m_W * z2;
var xx2 = rotation.m_X * x2;
var xy2 = rotation.m_X * y2;
var xz2 = rotation.m_X * z2;
var yy2 = rotation.m_Y * y2;
var yz2 = rotation.m_Y * z2;
var zz2 = rotation.m_Z * z2;
return new((value.m_X * (1 - yy2 - zz2)) + (value.m_Y * (xy2 - wz2)) + (value.m_Z * (xz2 + wy2)), (value.m_X * (xy2 + wz2)) + (value.m_Y * (1 - xx2 - zz2)) + (value.m_Z * (yz2 - wx2)), (value.m_X * (xz2 - wy2)) + (value.m_Y * (yz2 + wx2)) + (value.m_Z * (1 - xx2 - yy2)));
}
#endregion
#region -- public static methods --------------------------------------
/// <summary>
/// Calculates the length of the given vector.
/// </summary>
/// <returns>The Length of the given vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 Length(SFixPointVector3 value)
{
return value.Length();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int CompareLength(SFixPointVector3 value, FixPoint16 fLength)
{
unchecked
{
return ((value.m_X.m_Value * (long)value.m_X.m_Value) + (value.m_Y.m_Value * (long)value.m_Y.m_Value) + (value.m_Z.m_Value * (long)value.m_Z.m_Value)).CompareTo(fLength.m_Value * (long)fLength.m_Value);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int CompareLength(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
return ((value1.m_X.m_Value * (long)value1.m_X.m_Value) + (value1.m_Y.m_Value * (long)value1.m_Y.m_Value) + (value1.m_Z.m_Value * (long)value1.m_Z.m_Value)).CompareTo((value2.m_X.m_Value * (long)value2.m_X.m_Value) + (value2.m_Y.m_Value * (long)value2.m_Y.m_Value) + (value2.m_Z.m_Value * (long)value2.m_Z.m_Value));
}
}
/// <summary>
/// Computes the cross product of two vectors.
/// </summary>
/// <param name="vector1">The first vector.</param>
/// <param name="vector2">The second vector.</param>
/// <returns>The cross product.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Cross(SFixPointVector3 vector1, SFixPointVector3 vector2)
{
return new((vector1.m_Y * vector2.m_Z) - (vector1.m_Z * vector2.m_Y), (vector1.m_Z * vector2.m_X) - (vector1.m_X * vector2.m_Z), (vector1.m_X * vector2.m_Y) - (vector1.m_Y * vector2.m_X));
}
/// <summary>
/// Calculates the distance between two vectors. (manhatten/taxi-cab metrix)
/// </summary>
/// <param name="left">The source vector</param>
/// <param name="right">The source vector</param>
/// <returns>Distance between the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 DistanceManhattan(SFixPointVector3 left, SFixPointVector3 right)
{
return FixPoint16.Abs(left.m_X - right.m_X) + FixPoint16.Abs(left.m_Y - right.m_Y) + FixPoint16.Abs(left.m_Z - right.m_Z);
}
/// <summary>
/// Calculates the distance between two vectors.
/// </summary>
/// <param name="left">The source vector</param>
/// <param name="right">The source vector</param>
/// <returns>Distance between the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 Distance(SFixPointVector3 left, SFixPointVector3 right)
{
var dx = left.m_X - right.m_X;
var dy = left.m_Y - right.m_Y;
var dz = left.m_Z - right.m_Z;
return FixPoint16.Length(dx, dy, dz);
}
/// <summary>
/// Calculates the dot product of two vectors.
/// </summary>
/// <param name="left">The source vector.</param>
/// <param name="right">The source vector.</param>
/// <returns>The dot product of the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 Dot(SFixPointVector3 left, SFixPointVector3 right)
{
return (left.m_X * right.m_X) + (left.m_Y * right.m_Y) + (left.m_Z * right.m_Z);
}
/// <summary>
/// Calculates the dot product of two vectors.
/// </summary>
/// <param name="left">The source vector.</param>
/// <param name="right">The source vector.</param>
/// <returns>The dot product of the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16Long DotLong(SFixPointVector3 left, SFixPointVector3 right)
{
return new() { m_Value = (left.m_X.m_Value * (long)right.m_X.m_Value) + (left.m_Y.m_Value * (long)right.m_Y.m_Value) + (left.m_Z.m_Value * (long)right.m_Z.m_Value) };
}
/// <summary>
/// Calculates the sign of the dot product of two vectors.
/// </summary>
/// <param name="left">The source vector.</param>
/// <param name="right">The source vector.</param>
/// <returns>The sign of the dot product of the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int DotSign(SFixPointVector3 left, SFixPointVector3 right)
{
unchecked
{
return ((left.m_X.m_Value * (long)right.m_X.m_Value) + (left.m_Y.m_Value * (long)right.m_Y.m_Value) + (left.m_Z.m_Value * (long)right.m_Z.m_Value)).CompareTo(0);
}
}
/// <summary>
/// Creates a unit vector from the specified vector.
/// </summary>
/// <param name="value">The source vector.</param>
/// <returns>The created unit vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Normalize(SFixPointVector3 value)
{
return value.Normalized;
}
/// <summary>
/// Returns a vector that contains the lowest value from each matching pair of components.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <returns>The minimized vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Min(SFixPointVector3 value1, SFixPointVector3 value2)
{
SFixPointVector3 result;
result.m_X = FixPoint16.Min(value1.m_X, value2.m_X);
result.m_Y = FixPoint16.Min(value1.m_Y, value2.m_Y);
result.m_Z = FixPoint16.Min(value1.m_Z, value2.m_Z);
return result;
}
/// <summary>
/// Returns a vector that contains the highest value from each matching pair of components.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <returns>The maximized vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Max(SFixPointVector3 value1, SFixPointVector3 value2)
{
SFixPointVector3 result;
result.m_X = FixPoint16.Max(value1.m_X, value2.m_X);
result.m_Y = FixPoint16.Max(value1.m_Y, value2.m_Y);
result.m_Z = FixPoint16.Max(value1.m_Z, value2.m_Z);
return result;
}
/// <summary>
/// Returns a vector pointing in the opposite direction.
/// </summary>
/// <param name="value">The source vector.</param>
/// <returns>A new vector pointing in the opposite direction.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Negate(SFixPointVector3 value)
{
unchecked
{
SFixPointVector3 result;
result.m_X = -value.m_X;
result.m_Y = -value.m_Y;
result.m_Z = -value.m_Z;
return result;
}
}
/// <summary>
/// Adds two vectors
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <returns>A new vector representing the sum of the source vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Add(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X + value2.m_X;
result.m_Y = value1.m_Y + value2.m_Y;
result.m_Z = value1.m_Z + value2.m_Z;
return result;
}
}
/// <summary>
/// Adds a given scalar value to each component of a given vector.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The scalar value to be added to the vector.</param>
/// <returns>A new FixPointVector3 representing the sum of the given vector and scalar.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Add(SFixPointVector3 value1, FixPoint16 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X + value2;
result.m_Y = value1.m_Y + value2;
result.m_Z = value1.m_Z + value2;
return result;
}
}
/// <summary>
/// Subtracts a vector from another vector.
/// </summary>
/// <param name="value1">The vector to be subtracted from.</param>
/// <param name="value2">The vector to be subtracted.</param>
/// <returns>A new vector representing the result of the subtraction.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Subtract(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X - value2.m_X;
result.m_Y = value1.m_Y - value2.m_Y;
result.m_Z = value1.m_Z - value2.m_Z;
return result;
}
}
/// <summary>
/// Subtracts a given scalar value from each component of a given vector.
/// </summary>
/// <param name="value1">The vector to be subtracted from.</param>
/// <param name="value2">The scalar value to subtracted.</param>
/// <returns>A new FixPointVector3 representing the result of the subtraction.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Subtract(SFixPointVector3 value1, FixPoint16 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X - value2;
result.m_Y = value1.m_Y - value2;
result.m_Z = value1.m_Z - value2;
return result;
}
}
/// <summary>
/// Multiplies the components of two vectors by each other.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <returns>A new vector representing the result of the mulitiplication.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Multiply(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X * value2.m_X;
result.m_Y = value1.m_Y * value2.m_Y;
result.m_Z = value1.m_Z * value2.m_Z;
return result;
}
}
/// <summary>
/// Multiplies a vector by a scalar value.
/// </summary>
/// <param name="value">The source vector.</param>
/// <param name="scalar">The scalar value.</param>
/// <returns>A new vector representing the result of the mulitiplication.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Multiply(SFixPointVector3 value, FixPoint16 scalar)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value.m_X * scalar;
result.m_Y = value.m_Y * scalar;
result.m_Z = value.m_Z * scalar;
return result;
}
}
/// <summary>
/// Divides the components of a vector by the components of another vector.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The divisor vector.</param>
/// <returns>A new vector representing the result of the division.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Divide(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X / value2.m_X;
result.m_Y = value1.m_Y / value2.m_Y;
result.m_Z = value1.m_Z / value2.m_Z;
return result;
}
}
/// <summary>
/// Projects a vector onto another vector.
/// </summary>
/// <param name="projected">The projected vector.</param>
/// <param name="projectionTarget">The vector the projected vector is being projected on.</param>
/// <returns>A new vector representing the result of the division.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Project(SFixPointVector3 projected, SFixPointVector3 projectionTarget)
{
unchecked
{
SFixPointVector3 result;
var fpDot = Dot(projected, projectionTarget);
var fpProjectionTargetLength = projectionTarget.Length();
var fpScalar = fpDot / (fpProjectionTargetLength * fpProjectionTargetLength);
result = projectionTarget * fpScalar;
return result;
}
}
/// <summary>
/// Divides a vector by a scalar value.
/// </summary>
/// <param name="value">The source vector.</param>
/// <param name="divider">The divider</param>
/// <returns>A new vector representing the result of the division.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Divide(SFixPointVector3 value, FixPoint16 divider)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value.m_X / divider;
result.m_Y = value.m_Y / divider;
result.m_Z = value.m_Z / divider;
return result;
}
}
/// <summary>
/// Creates a new FixPointVector3 with each component being the result of dividing a scalar value by the corresponding
/// component of a vector.
/// </summary>
/// <param name="value">The scalar value to be divided.</param>
/// <param name="divider">The divider vector</param>
/// <returns>
/// A new vector with each component being the result of dividing the scalar value by the corresponding component
/// of the vector.
/// </returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Divide(FixPoint16 value, SFixPointVector3 divider)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value / divider.m_X;
result.m_Y = value / divider.m_Y;
result.m_Z = value / divider.m_Z;
return result;
}
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <param name="amount">
/// The value between 0 and 1 indicating the weight of _value2. '0.0' will cause _value1 to be
/// returned; '1.0' will cause _value2 to be returned.
/// </param>
/// <returns>The linear interpolation of the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Lerp(SFixPointVector3 value1, SFixPointVector3 value2, FixPoint16 amount)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X + (amount * (value2.m_X - value1.m_X));
result.m_Y = value1.m_Y + (amount * (value2.m_Y - value1.m_Y));
result.m_Z = value1.m_Z + (amount * (value2.m_Z - value1.m_Z));
return result;
}
}
/// <summary>
/// Interpolates between two vectors using a cubic equation.
/// </summary>
/// <param name="value1">The source value.</param>
/// <param name="value2">The source value.</param>
/// <param name="amount">The weighting value.</param>
/// <returns>The interpolated value.</returns>
public static SFixPointVector3 SmoothStep(SFixPointVector3 value1, SFixPointVector3 value2, FixPoint16 amount)
{
var smootstep = FixPoint16.Min(0, FixPoint16.Max(1, amount));
smootstep = smootstep * smootstep * (3 - (2 * smootstep));
return Lerp(value1, value2, smootstep);
}
/// <summary>
/// Returns a FixPointVector3 containing the 2D Cartesian coordinates of a point specified in barycentric (areal)
/// coordinates relative to a 2D triangle.
/// </summary>
/// <param name="value1">A FixPointVector3 containing the 2D Cartesian coordinates of vertex 1 of the triangle.</param>
/// <param name="value2">A FixPointVector3 containing the 2D Cartesian coordinates of vertex 2 of the triangle.</param>
/// <param name="value3">A FixPointVector3 containing the 2D Cartesian coordinates of vertex 3 of the triangle.</param>
/// <param name="amount1">
/// Barycentric coordinate b2, which expresses the weighting factor toward vertex 2 (specified in
/// _value2).
/// </param>
/// <param name="amount2">
/// Barycentric coordinate b3, which expresses the weighting factor toward vertex 3 (specified in
/// _value3).
/// </param>
/// <returns>A new FixPointVector3 containing the 2D Cartesian coordinates of the specified point.</returns>
public static SFixPointVector3 Barycentric(SFixPointVector3 value1, SFixPointVector3 value2, SFixPointVector3 value3, FixPoint16 amount1, FixPoint16 amount2)
{
SFixPointVector3 result;
result.m_X = value1.m_X + (amount1 * (value2.m_X - value1.m_X)) + (amount2 * (value3.m_X - value1.m_X));
result.m_Y = value1.m_Y + (amount1 * (value2.m_Y - value1.m_Y)) + (amount2 * (value3.m_Y - value1.m_Y));
result.m_Z = value1.m_Z + (amount1 * (value2.m_Z - value1.m_Z)) + (amount2 * (value3.m_Z - value1.m_Z));
return result;
}
public static SFixPointVector3 Fract(SFixPointVector3 p)
{
return new(FixPoint16.Fract(p.m_X), FixPoint16.Fract(p.m_Y), FixPoint16.Fract(p.m_Z));
}
#endregion
#region -- operators --------------------------------------------------
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool operator ==(SFixPointVector3 left, SFixPointVector3 right)
{
return left.Equals(right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool operator !=(SFixPointVector3 left, SFixPointVector3 right)
{
return !left.Equals(right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator -(SFixPointVector3 value)
{
return Negate(value);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator +(SFixPointVector3 left, SFixPointVector3 right)
{
unchecked
{
SFixPointVector3 result;
result.m_X = left.m_X + right.m_X;
result.m_Y = left.m_Y + right.m_Y;
result.m_Z = left.m_Z + right.m_Z;
return result;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator +(SFixPointVector3 left, FixPoint16 right)
{
return Add(left, right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator -(SFixPointVector3 left, SFixPointVector3 right)
{
unchecked
{
SFixPointVector3 result;
result.m_X = left.m_X - right.m_X;
result.m_Y = left.m_Y - right.m_Y;
result.m_Z = left.m_Z - right.m_Z;
return result;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator -(SFixPointVector3 left, FixPoint16 right)
{
return Subtract(left, right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator *(SFixPointVector3 left, SFixPointVector3 right)
{
return Multiply(left, right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator *(SFixPointVector3 left, FixPoint16 right)
{
return new()
{
m_X = left.m_X * right,
m_Y = left.m_Y * right,
m_Z = left.m_Z * right
};
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator *(FixPoint16 left, SFixPointVector3 right)
{
return Multiply(right, left);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator /(SFixPointVector3 left, SFixPointVector3 right)
{
return Divide(left, right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator /(SFixPointVector3 left, FixPoint16 right)
{
return new()
{
m_X = left.m_X / right,
m_Y = left.m_Y / right,
m_Z = left.m_Z / right
};
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator /(FixPoint16 left, SFixPointVector3 right)
{
return Divide(left, right);
}
#endregion
/// <summary>
/// The x-component of the vector.
/// </summary>
public FixPoint16 m_X;
/// <summary>
/// The y-component of the vector.
/// </summary>
public FixPoint16 m_Y;
/// <summary>
/// The z-component of the vector.
/// </summary>
public FixPoint16 m_Z;
}

View File

@@ -1,34 +0,0 @@
namespace SideScrollerGame.Sim.Math;
internal static class IntMath
{
public static int Sign(int value)
{
return System.Math.Sign(value);
}
public static int Abs(int value)
{
return System.Math.Abs(value);
}
public static int Min(int left, int right)
{
return System.Math.Min(left, right);
}
public static int Max(int left, int right)
{
return System.Math.Max(left, right);
}
public static int Clamp(int value, int min, int max)
{
return System.Math.Clamp(value, min, max);
}
public static long Sqrt(long value)
{
return (long)System.Math.Sqrt(value);
}
}

View File

@@ -1,119 +0,0 @@
using System;
namespace SideScrollerGame.Sim.Math;
/// <summary>
/// Implements a XorShift* PRNG, with 64 bits of internal state.
/// See http://en.wikipedia.org/wiki/Xorshift
/// </summary>
public struct SIntRandom
{
public SIntRandom(ulong seed)
{
if (seed == 0)
throw new InvalidOperationException("Seed needs to be bigger than zero.");
m_Seed = seed;
}
public ulong Next()
{
m_Seed ^= m_Seed >> 12;
m_Seed ^= m_Seed << 25;
m_Seed ^= m_Seed >> 27;
return m_Seed * 2685821657736338717UL; // multiplier taken from wikipedia article on XorShift PRNGs
}
public ulong Next(ulong upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
return Next() % upperLimit;
}
public FixPoint16 NextFixPoint16()
{
return new() { m_Value = (int)(Next() & 0xffffUL) };
}
public int RandomizedRound(FixPoint16 value)
{
int ret = value.ToIntFloor();
if (NextFixPoint16() < FixPoint16.Fract(value))
ret++;
return ret;
}
public uint Next(uint upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
return (uint)(Next() % upperLimit);
}
public uint Next(uint lowerLimit, uint upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
if (lowerLimit == upperLimit)
{
return lowerLimit;
}
return lowerLimit + (uint)(Next() % (upperLimit - lowerLimit));
}
public int Next(int upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
return (int)(Next() % (uint)(upperLimit & 0x7fffffff));
}
public int Next(int lowerLimit, int upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
if (lowerLimit == upperLimit)
{
return lowerLimit;
}
return lowerLimit + (int)(Next() % (uint)((upperLimit - lowerLimit) & 0x7fffffff));
}
public double NextDouble()
{
return Next(int.MaxValue) * (1.0 / int.MaxValue);
}
public float NextSingle()
{
return (float)NextDouble();
}
public override readonly string ToString()
{
return $"0x{m_Seed:X}";
}
public readonly ulong Seed => m_Seed;
private ulong m_Seed;
}

View File

@@ -1,3 +0,0 @@
namespace SideScrollerGame.Sim;
public readonly record struct PlayerId(int Value);

View File

@@ -1,18 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Input;
namespace SideScrollerGame.Sim.Replay;
[ExcludeFromCodeCoverage]
public sealed record RecordedTick
{
public RecordedTick(TickActionBatch actionBatch, int expectedStateHash)
{
ActionBatch = actionBatch;
ExpectedStateHash = expectedStateHash;
}
public TickActionBatch ActionBatch { get; init; }
public int ExpectedStateHash { get; init; }
}

View File

@@ -1,30 +0,0 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Serialization;
namespace SideScrollerGame.Sim.Replay;
public static class ReplayPlayer
{
public static ImmutableArray<int> Play(ReplayRecord replay, GameDefinition gameDefinition, SimulationConfig config)
{
if (replay.ContentHash != GameDefinitionHasher.Compute(gameDefinition))
throw new InvalidOperationException("Replay content hash does not match the supplied game definition.");
if (replay.TicksPerSecond != config.TicksPerSecond)
throw new InvalidOperationException("Replay tick rate does not match the supplied simulation config.");
var hashes = ImmutableArray.CreateBuilder<int>(replay.Ticks.Length);
Simulation simulation = new(gameDefinition, config, replay.Seed);
foreach (var recordedTick in replay.Ticks)
{
var result = simulation.Step(recordedTick.ActionBatch);
if (result.StateHash != recordedTick.ExpectedStateHash)
throw new InvalidOperationException($"Replay diverged at tick {recordedTick.ActionBatch.Tick}.");
hashes.Add(result.StateHash);
}
return hashes.MoveToImmutable();
}
}

View File

@@ -1,24 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Replay;
[ExcludeFromCodeCoverage]
public sealed record ReplayRecord
{
public ReplayRecord(int contentHash, int seed, int ticksPerSecond, ImmutableArray<RecordedTick> ticks)
{
ContentHash = contentHash;
Seed = seed;
TicksPerSecond = ticksPerSecond;
Ticks = ticks.IsDefault ? ImmutableArray<RecordedTick>.Empty : ticks;
}
public int ContentHash { get; init; }
public int Seed { get; init; }
public int TicksPerSecond { get; init; }
public ImmutableArray<RecordedTick> Ticks { get; init; }
}

View File

@@ -1,27 +0,0 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Runtime;
using SideScrollerGame.Sim.Serialization;
namespace SideScrollerGame.Sim.Replay;
public sealed class ReplayRecorder
{
public ReplayRecorder()
{
m_Ticks = new();
}
public void Append(TickActionBatch actionBatch, TickResult tickResult)
{
m_Ticks.Add(new(actionBatch, tickResult.StateHash));
}
public ReplayRecord Build(GameDefinition gameDefinition, SimulationConfig config, int seed)
{
return new(GameDefinitionHasher.Compute(gameDefinition), seed, config.TicksPerSecond, m_Ticks.ToImmutableArray());
}
private readonly List<RecordedTick> m_Ticks;
}

View File

@@ -1,33 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record PlayerSnapshot
{
public PlayerSnapshot(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, int health, FixPoint16 verticalVelocity, bool isGrounded)
{
PlayerId = playerId;
Position = position;
MoveAxisX = moveAxisX;
MoveAxisY = moveAxisY;
Health = health;
VerticalVelocity = verticalVelocity;
IsGrounded = isGrounded;
}
public PlayerId PlayerId { get; init; }
public FixPointVector2 Position { get; init; }
public sbyte MoveAxisX { get; init; }
public sbyte MoveAxisY { get; init; }
public int Health { get; init; }
public FixPoint16 VerticalVelocity { get; init; }
public bool IsGrounded { get; init; }
}

View File

@@ -1,133 +0,0 @@
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Runtime;
public sealed class PlayerState
{
public PlayerState(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, short aimAxisX, short aimAxisY, int selectedWeaponSlot, int buttonMask, int health, FixPoint16 verticalVelocity, bool isGrounded, int lastGroundedTick, int bufferedJumpTick)
{
PlayerId = playerId;
Position = position;
MoveAxisX = moveAxisX;
MoveAxisY = moveAxisY;
AimAxisX = aimAxisX;
AimAxisY = aimAxisY;
SelectedWeaponSlot = selectedWeaponSlot;
ButtonMask = buttonMask;
Health = health;
VerticalVelocity = verticalVelocity;
IsGrounded = isGrounded;
LastGroundedTick = lastGroundedTick;
BufferedJumpTick = bufferedJumpTick;
}
public PlayerState Clone()
{
return new(PlayerId, Position, MoveAxisX, MoveAxisY, AimAxisX, AimAxisY, SelectedWeaponSlot, ButtonMask, Health, VerticalVelocity, IsGrounded, LastGroundedTick, BufferedJumpTick);
}
public void SetMoveAxis(sbyte x, sbyte y)
{
MoveAxisX = x;
MoveAxisY = y;
}
public void SetAimAxis(short x, short y)
{
AimAxisX = x;
AimAxisY = y;
}
public void SetButton(InputButton button, bool isPressed)
{
var mask = 1 << (int)button;
ButtonMask = isPressed ? ButtonMask | mask : ButtonMask & ~mask;
}
public void SelectWeaponSlot(int slotIndex)
{
SelectedWeaponSlot = slotIndex;
}
public bool IsButtonPressed(InputButton button)
{
return (ButtonMask & (1 << (int)button)) != 0;
}
public void Advance()
{
Position += new FixPointVector2(MoveAxisX, MoveAxisY);
}
public void ApplyDamage(int damage)
{
Health = System.Math.Max(0, Health - damage);
}
public void BufferJump(int tick)
{
BufferedJumpTick = tick;
}
public bool HasBufferedJump(int tick, int jumpBufferTicks)
{
return BufferedJumpTick >= 0 && tick - BufferedJumpTick <= jumpBufferTicks;
}
public void ConsumeBufferedJump()
{
BufferedJumpTick = -1;
}
public void SetPosition(FixPointVector2 position)
{
Position = position;
}
public void SetVerticalVelocity(FixPoint16 verticalVelocity)
{
VerticalVelocity = verticalVelocity;
}
public void SetGrounded(bool isGrounded, int tick)
{
IsGrounded = isGrounded;
if (isGrounded)
LastGroundedTick = tick;
}
public void LeaveGround(int tick)
{
if (IsGrounded)
LastGroundedTick = tick;
IsGrounded = false;
}
public PlayerId PlayerId { get; }
public FixPointVector2 Position { get; private set; }
public sbyte MoveAxisX { get; private set; }
public sbyte MoveAxisY { get; private set; }
public short AimAxisX { get; private set; }
public short AimAxisY { get; private set; }
public int SelectedWeaponSlot { get; private set; }
public int ButtonMask { get; private set; }
public int Health { get; private set; }
public FixPoint16 VerticalVelocity { get; private set; }
public bool IsGrounded { get; private set; }
public int LastGroundedTick { get; private set; }
public int BufferedJumpTick { get; private set; }
}

View File

@@ -1,20 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record SimulationEvent
{
public SimulationEvent(string kind, int tick, PlayerId playerId)
{
Kind = kind;
Tick = tick;
PlayerId = playerId;
}
public string Kind { get; init; }
public int Tick { get; init; }
public PlayerId PlayerId { get; init; }
}

View File

@@ -1,64 +0,0 @@
using System.Collections.Immutable;
namespace SideScrollerGame.Sim.Runtime;
public sealed class SimulationState
{
public SimulationState(int tick, int seed, ulong randomState, ulong lastRandomValue, ImmutableArray<PlayerState> players, ImmutableHashSet<string> activatedTriggerIds)
{
Tick = tick;
Seed = seed;
RandomState = randomState;
LastRandomValue = lastRandomValue;
Players = players.IsDefault ? ImmutableArray<PlayerState>.Empty : players;
ActivatedTriggerIds = activatedTriggerIds == default ? ImmutableHashSet<string>.Empty : activatedTriggerIds;
}
public SimulationState Clone()
{
var builder = ImmutableArray.CreateBuilder<PlayerState>(Players.Length);
foreach (var player in Players)
builder.Add(player.Clone());
return new(Tick, Seed, RandomState, LastRandomValue, builder.MoveToImmutable(), ActivatedTriggerIds);
}
public PlayerState GetRequiredPlayer(PlayerId playerId)
{
foreach (var player in Players)
{
if (player.PlayerId == playerId)
return player;
}
throw new InvalidOperationException($"Unknown player id {playerId.Value}.");
}
public void AdvanceTick(int tick, ulong randomState, ulong lastRandomValue)
{
Tick = tick;
RandomState = randomState;
LastRandomValue = lastRandomValue;
}
public bool ActivateTrigger(string triggerId)
{
if (ActivatedTriggerIds.Contains(triggerId))
return false;
ActivatedTriggerIds = ActivatedTriggerIds.Add(triggerId);
return true;
}
public int Tick { get; private set; }
public int Seed { get; }
public ulong RandomState { get; private set; }
public ulong LastRandomValue { get; private set; }
public ImmutableArray<PlayerState> Players { get; }
public ImmutableHashSet<string> ActivatedTriggerIds { get; private set; }
}

View File

@@ -1,24 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record TickResult
{
public TickResult(ImmutableArray<SimulationEvent> events, int stateHash, WorldSnapshot previousSnapshot, WorldSnapshot currentSnapshot)
{
Events = events.IsDefault ? ImmutableArray<SimulationEvent>.Empty : events;
StateHash = stateHash;
PreviousSnapshot = previousSnapshot;
CurrentSnapshot = currentSnapshot;
}
public ImmutableArray<SimulationEvent> Events { get; init; }
public int StateHash { get; init; }
public WorldSnapshot PreviousSnapshot { get; init; }
public WorldSnapshot CurrentSnapshot { get; init; }
}

View File

@@ -1,24 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record WorldSnapshot
{
public WorldSnapshot(int tick, int stateHash, ulong lastRandomValue, ImmutableArray<PlayerSnapshot> players)
{
Tick = tick;
StateHash = stateHash;
LastRandomValue = lastRandomValue;
Players = players.IsDefault ? ImmutableArray<PlayerSnapshot>.Empty : players;
}
public int Tick { get; init; }
public int StateHash { get; init; }
public ulong LastRandomValue { get; init; }
public ImmutableArray<PlayerSnapshot> Players { get; init; }
}

View File

@@ -1,25 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Serialization;
[ExcludeFromCodeCoverage]
internal static class DeterministicHash
{
public static int Compute(byte[] data)
{
unchecked
{
const uint offset = 2166136261;
const uint prime = 16777619;
var hash = offset;
foreach (var value in data)
{
hash ^= value;
hash *= prime;
}
return (int)hash;
}
}
}

View File

@@ -1,172 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using SideScrollerGame.Sim.Definitions;
namespace SideScrollerGame.Sim.Serialization;
[ExcludeFromCodeCoverage]
internal static class GameDefinitionHasher
{
[ExcludeFromCodeCoverage]
private sealed record GameDefinitionDocument
{
public LevelDefinitionDocument Level { get; init; } = null!;
public ImmutableArray<PlayerDefinitionDocument> Players { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record PlayerDefinitionDocument
{
public int PlayerId { get; init; }
public int SpawnX { get; init; }
public int SpawnY { get; init; }
public int MaxHealth { get; init; }
public bool UsesPlatformerMotion { get; init; }
public int MoveSpeedPerTick { get; init; }
public int GravityPerTick { get; init; }
public int JumpVelocityPerTick { get; init; }
public int CoyoteTicks { get; init; }
public int JumpBufferTicks { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record LevelDefinitionDocument
{
public BoundsDocument WorldBounds { get; init; } = null!;
public ImmutableArray<HazardDefinitionDocument> Hazards { get; init; }
public ImmutableArray<TriggerDefinitionDocument> Triggers { get; init; }
public ImmutableArray<SolidPlatformDefinitionDocument> Platforms { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record BoundsDocument
{
public int MinX { get; init; }
public int MinY { get; init; }
public int MaxX { get; init; }
public int MaxY { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record HazardDefinitionDocument
{
public string Id { get; init; } = string.Empty;
public BoundsDocument Bounds { get; init; } = null!;
public int DamagePerTick { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record TriggerDefinitionDocument
{
public string Id { get; init; } = string.Empty;
public BoundsDocument Bounds { get; init; } = null!;
public string Kind { get; init; } = string.Empty;
}
[ExcludeFromCodeCoverage]
private sealed record SolidPlatformDefinitionDocument
{
public string Id { get; init; } = string.Empty;
public BoundsDocument Bounds { get; init; } = null!;
}
public static int Compute(GameDefinition gameDefinition)
{
List<PlayerDefinitionDocument> players = new(gameDefinition.Players.Length);
foreach (var player in gameDefinition.Players)
{
players.Add(new()
{
PlayerId = player.PlayerId.Value,
SpawnX = player.SpawnPosition.m_X.m_Value,
SpawnY = player.SpawnPosition.m_Y.m_Value,
MaxHealth = player.MaxHealth,
UsesPlatformerMotion = player.UsesPlatformerMotion,
MoveSpeedPerTick = player.MoveSpeedPerTick.m_Value,
GravityPerTick = player.GravityPerTick.m_Value,
JumpVelocityPerTick = player.JumpVelocityPerTick.m_Value,
CoyoteTicks = player.CoyoteTicks,
JumpBufferTicks = player.JumpBufferTicks
});
}
List<HazardDefinitionDocument> hazards = new(gameDefinition.Level.Hazards.Length);
foreach (var hazard in gameDefinition.Level.Hazards)
{
hazards.Add(new()
{
Id = hazard.Id,
Bounds = ToDocument(hazard.Bounds),
DamagePerTick = hazard.DamagePerTick
});
}
List<TriggerDefinitionDocument> triggers = new(gameDefinition.Level.Triggers.Length);
foreach (var trigger in gameDefinition.Level.Triggers)
{
triggers.Add(new()
{
Id = trigger.Id,
Bounds = ToDocument(trigger.Bounds),
Kind = trigger.Kind
});
}
List<SolidPlatformDefinitionDocument> platforms = new(gameDefinition.Level.Platforms.Length);
foreach (var platform in gameDefinition.Level.Platforms)
{
platforms.Add(new()
{
Id = platform.Id,
Bounds = ToDocument(platform.Bounds)
});
}
var bytes = JsonSerializer.SerializeToUtf8Bytes(new GameDefinitionDocument
{
Level = new()
{
WorldBounds = ToDocument(gameDefinition.Level.WorldBounds),
Hazards = hazards.ToImmutableArray(),
Triggers = triggers.ToImmutableArray(),
Platforms = platforms.ToImmutableArray()
},
Players = players.ToImmutableArray()
});
return DeterministicHash.Compute(bytes);
}
private static BoundsDocument ToDocument(AxisAlignedBounds bounds)
{
return new()
{
MinX = bounds.Min.m_X.m_Value,
MinY = bounds.Min.m_Y.m_Value,
MaxX = bounds.Max.m_X.m_Value,
MaxY = bounds.Max.m_Y.m_Value
};
}
}

View File

@@ -1,146 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Replay;
namespace SideScrollerGame.Sim.Serialization;
[ExcludeFromCodeCoverage]
public static class ReplayRecordSerializer
{
[ExcludeFromCodeCoverage]
private sealed record ReplayRecordDocument
{
public int Version { get; init; }
public int ContentHash { get; init; }
public int Seed { get; init; }
public int TicksPerSecond { get; init; }
public ImmutableArray<RecordedTickDocument> Ticks { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record RecordedTickDocument
{
public int Tick { get; init; }
public int ExpectedStateHash { get; init; }
public ImmutableArray<SimulationActionDocument> Actions { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record SimulationActionDocument
{
public string Kind { get; init; } = string.Empty;
public int PlayerId { get; init; }
public int X { get; init; }
public int Y { get; init; }
public InputButton Button { get; init; }
public bool IsPressed { get; init; }
public int SlotIndex { get; init; }
}
public static byte[] Serialize(ReplayRecord replayRecord)
{
List<RecordedTickDocument> ticks = new(replayRecord.Ticks.Length);
foreach (var tick in replayRecord.Ticks)
{
List<SimulationActionDocument> actions = new(tick.ActionBatch.Actions.Length);
foreach (var action in tick.ActionBatch.Actions)
actions.Add(ToDocument(action));
ticks.Add(new()
{
Tick = tick.ActionBatch.Tick,
ExpectedStateHash = tick.ExpectedStateHash,
Actions = actions.ToImmutableArray()
});
}
return JsonSerializer.SerializeToUtf8Bytes(new ReplayRecordDocument
{
Version = SimulationDefaults.ReplayFormatVersion,
ContentHash = replayRecord.ContentHash,
Seed = replayRecord.Seed,
TicksPerSecond = replayRecord.TicksPerSecond,
Ticks = ticks.ToImmutableArray()
});
}
public static ReplayRecord Deserialize(byte[] data)
{
var document = JsonSerializer.Deserialize<ReplayRecordDocument>(data) ?? throw new InvalidOperationException("Replay payload was empty.");
if (document.Version != SimulationDefaults.ReplayFormatVersion)
throw new NotSupportedException($"Unsupported replay version {document.Version}.");
var ticks = ImmutableArray.CreateBuilder<RecordedTick>(document.Ticks.Length);
foreach (var tick in document.Ticks)
{
var actions = ImmutableArray.CreateBuilder<SimulationAction>(tick.Actions.Length);
foreach (var action in tick.Actions)
actions.Add(ToAction(action));
ticks.Add(new(new(tick.Tick, actions.MoveToImmutable()), tick.ExpectedStateHash));
}
return new(document.ContentHash, document.Seed, document.TicksPerSecond, ticks.MoveToImmutable());
}
private static SimulationActionDocument ToDocument(SimulationAction action)
{
return action switch
{
MoveAxisChanged moveAxisChanged => new()
{
Kind = nameof(MoveAxisChanged),
PlayerId = moveAxisChanged.PlayerId.Value,
X = moveAxisChanged.X,
Y = moveAxisChanged.Y
},
AimAxisChanged aimAxisChanged => new()
{
Kind = nameof(AimAxisChanged),
PlayerId = aimAxisChanged.PlayerId.Value,
X = aimAxisChanged.X,
Y = aimAxisChanged.Y
},
ButtonChanged buttonChanged => new()
{
Kind = nameof(ButtonChanged),
PlayerId = buttonChanged.PlayerId.Value,
Button = buttonChanged.Button,
IsPressed = buttonChanged.IsPressed
},
WeaponSlotSelected weaponSlotSelected => new()
{
Kind = nameof(WeaponSlotSelected),
PlayerId = weaponSlotSelected.PlayerId.Value,
SlotIndex = weaponSlotSelected.SlotIndex
},
_ => throw new NotSupportedException($"Unsupported action type {action.GetType().Name}.")
};
}
private static SimulationAction ToAction(SimulationActionDocument action)
{
return action.Kind switch
{
nameof(MoveAxisChanged) => new MoveAxisChanged(new(action.PlayerId), checked((sbyte)action.X), checked((sbyte)action.Y)),
nameof(AimAxisChanged) => new AimAxisChanged(new(action.PlayerId), checked((short)action.X), checked((short)action.Y)),
nameof(ButtonChanged) => new ButtonChanged(new(action.PlayerId), action.Button, action.IsPressed),
nameof(WeaponSlotSelected) => new WeaponSlotSelected(new(action.PlayerId), action.SlotIndex),
_ => throw new NotSupportedException($"Unsupported action kind {action.Kind}.")
};
}
}

View File

@@ -1,110 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using SideScrollerGame.Sim.Math;
using SideScrollerGame.Sim.Runtime;
namespace SideScrollerGame.Sim.Serialization;
[ExcludeFromCodeCoverage]
internal static class SimulationStateSerializer
{
[ExcludeFromCodeCoverage]
private sealed record SimulationStateDocument
{
public int Version { get; init; }
public int Tick { get; init; }
public int Seed { get; init; }
public ulong RandomState { get; init; }
public ulong LastRandomValue { get; init; }
public ImmutableArray<PlayerStateDocument> Players { get; init; }
public ImmutableArray<string> ActivatedTriggerIds { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record PlayerStateDocument
{
public int PlayerId { get; init; }
public int PositionX { get; init; }
public int PositionY { get; init; }
public sbyte MoveAxisX { get; init; }
public sbyte MoveAxisY { get; init; }
public short AimAxisX { get; init; }
public short AimAxisY { get; init; }
public int SelectedWeaponSlot { get; init; }
public int ButtonMask { get; init; }
public int Health { get; init; }
public int VerticalVelocity { get; init; }
public bool IsGrounded { get; init; }
public int LastGroundedTick { get; init; }
public int BufferedJumpTick { get; init; }
}
public static byte[] Serialize(SimulationState state)
{
List<PlayerStateDocument> players = new(state.Players.Length);
foreach (var player in state.Players)
{
players.Add(new()
{
PlayerId = player.PlayerId.Value,
PositionX = player.Position.m_X.m_Value,
PositionY = player.Position.m_Y.m_Value,
MoveAxisX = player.MoveAxisX,
MoveAxisY = player.MoveAxisY,
AimAxisX = player.AimAxisX,
AimAxisY = player.AimAxisY,
SelectedWeaponSlot = player.SelectedWeaponSlot,
ButtonMask = player.ButtonMask,
Health = player.Health,
VerticalVelocity = player.VerticalVelocity.m_Value,
IsGrounded = player.IsGrounded,
LastGroundedTick = player.LastGroundedTick,
BufferedJumpTick = player.BufferedJumpTick
});
}
return JsonSerializer.SerializeToUtf8Bytes(new SimulationStateDocument
{
Version = SimulationDefaults.StateFormatVersion,
Tick = state.Tick,
Seed = state.Seed,
RandomState = state.RandomState,
LastRandomValue = state.LastRandomValue,
Players = players.ToImmutableArray(),
ActivatedTriggerIds = state.ActivatedTriggerIds.Order(StringComparer.Ordinal).ToImmutableArray()
});
}
public static SimulationState Deserialize(byte[] data)
{
var document = JsonSerializer.Deserialize<SimulationStateDocument>(data) ?? throw new InvalidOperationException("Simulation state payload was empty.");
if (document.Version != SimulationDefaults.StateFormatVersion)
throw new NotSupportedException($"Unsupported simulation state version {document.Version}.");
var players = ImmutableArray.CreateBuilder<PlayerState>(document.Players.Length);
foreach (var player in document.Players)
players.Add(new(new(player.PlayerId), new(new() { m_Value = player.PositionX }, new FixPoint16 { m_Value = player.PositionY }), player.MoveAxisX, player.MoveAxisY, player.AimAxisX, player.AimAxisY, player.SelectedWeaponSlot, player.ButtonMask, player.Health, new() { m_Value = player.VerticalVelocity }, player.IsGrounded, player.LastGroundedTick, player.BufferedJumpTick));
return new(document.Tick, document.Seed, document.RandomState, document.LastRandomValue, players.MoveToImmutable(), document.ActivatedTriggerIds.ToImmutableHashSet(StringComparer.Ordinal));
}
}

View File

@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -1,410 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
using SideScrollerGame.Sim.Runtime;
using SideScrollerGame.Sim.Serialization;
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim;
public sealed class Simulation
{
public Simulation(GameDefinition gameDefinition, SimulationConfig config, int seed) : this(gameDefinition, config, CreateInitialState(gameDefinition, seed), true)
{
}
private Simulation(GameDefinition gameDefinition, SimulationConfig config, SimulationState initialState, bool enableVerification)
{
m_GameDefinition = gameDefinition ?? throw new ArgumentNullException(nameof(gameDefinition));
m_Config = config ?? throw new ArgumentNullException(nameof(config));
m_EnableVerification = enableVerification;
ValidateDefinitions(gameDefinition);
CurrentState = initialState;
var initialHash = ComputeStateHash(initialState);
PreviousSnapshot = CreateSnapshot(initialState, initialHash);
CurrentSnapshot = PreviousSnapshot;
}
public TickResult Step(in TickActionBatch actions)
{
if (actions.Tick != CurrentTick + 1)
throw new InvalidOperationException($"Expected tick {CurrentTick + 1} but received {actions.Tick}.");
TickResult? cloneResult = null;
if (m_EnableVerification && m_Config.VerificationMode == VerificationMode.RoundTripAndStepClone)
{
var clone = LoadStateCore(SaveState(), m_GameDefinition, m_Config, false);
cloneResult = clone.Step(actions);
}
PreviousSnapshot = CurrentSnapshot;
List<SimulationEvent> events = new();
ApplyActions(actions.Tick, actions);
var nextRandomState = AdvanceRandom();
AdvancePlayers(actions.Tick, events);
ResolveBounds(actions.Tick, events);
ResolveHazards(actions.Tick, events);
ResolveTriggers(actions.Tick, events);
CurrentState.AdvanceTick(actions.Tick, nextRandomState, nextRandomState);
var stateHash = ComputeStateHash(CurrentState);
CurrentSnapshot = CreateSnapshot(CurrentState, stateHash);
ValidateRoundTripState(stateHash);
ValidateCloneResult(cloneResult, stateHash, actions.Tick);
return new(events.ToImmutableArray(), stateHash, PreviousSnapshot, CurrentSnapshot);
}
public byte[] SaveState()
{
return SimulationStateSerializer.Serialize(CurrentState);
}
public static Simulation LoadState(byte[] data, GameDefinition gameDefinition, SimulationConfig config)
{
return LoadStateCore(data, gameDefinition, config, true);
}
private static Simulation LoadStateCore(byte[] data, GameDefinition gameDefinition, SimulationConfig config, bool enableVerification)
{
var state = SimulationStateSerializer.Deserialize(data);
return new(gameDefinition, config, state, enableVerification);
}
private static SimulationState CreateInitialState(GameDefinition gameDefinition, int seed)
{
ArgumentNullException.ThrowIfNull(gameDefinition);
ValidateDefinitions(gameDefinition);
var players = ImmutableArray.CreateBuilder<PlayerState>(gameDefinition.Players.Length);
foreach (var player in gameDefinition.Players)
{
var isGrounded = IsSupported(gameDefinition.Level, player.SpawnPosition);
players.Add(new(player.PlayerId, player.SpawnPosition, 0, 0, 0, 0, 0, 0, player.MaxHealth, FixPoint16.Zero, isGrounded, isGrounded ? 0 : -1, -1));
}
var normalizedSeed = NormalizeSeed(seed);
return new(0, seed, normalizedSeed, 0, players.MoveToImmutable(), ImmutableHashSet<string>.Empty);
}
private static ulong NormalizeSeed(int seed)
{
return seed == 0 ? 1UL : unchecked((uint)seed);
}
private static void ValidateDefinitions(GameDefinition gameDefinition)
{
HashSet<int> seen = new();
foreach (var player in gameDefinition.Players)
{
if (!seen.Add(player.PlayerId.Value))
throw new InvalidOperationException($"Duplicate player id {player.PlayerId.Value}.");
if (player.MaxHealth <= 0)
throw new InvalidOperationException($"Player {player.PlayerId.Value} must have positive health.");
if (!gameDefinition.Level.WorldBounds.Contains(player.SpawnPosition))
throw new InvalidOperationException($"Player {player.PlayerId.Value} spawn must start inside world bounds.");
if (player.MoveSpeedPerTick < FixPoint16.Zero)
throw new InvalidOperationException($"Player {player.PlayerId.Value} move speed must be non-negative.");
if (player.GravityPerTick < FixPoint16.Zero)
throw new InvalidOperationException($"Player {player.PlayerId.Value} gravity must be non-negative.");
if (player.JumpVelocityPerTick < FixPoint16.Zero)
throw new InvalidOperationException($"Player {player.PlayerId.Value} jump velocity must be non-negative.");
if (player.CoyoteTicks < 0)
throw new InvalidOperationException($"Player {player.PlayerId.Value} coyote ticks must be non-negative.");
if (player.JumpBufferTicks < 0)
throw new InvalidOperationException($"Player {player.PlayerId.Value} jump buffer ticks must be non-negative.");
}
}
private static int ComputeStateHash(SimulationState state)
{
return DeterministicHash.Compute(SimulationStateSerializer.Serialize(state));
}
private static WorldSnapshot CreateSnapshot(SimulationState state, int stateHash)
{
var players = ImmutableArray.CreateBuilder<PlayerSnapshot>(state.Players.Length);
foreach (var player in state.Players)
players.Add(new(player.PlayerId, player.Position, player.MoveAxisX, player.MoveAxisY, player.Health, player.VerticalVelocity, player.IsGrounded));
return new(state.Tick, stateHash, state.LastRandomValue, players.MoveToImmutable());
}
private void ApplyActions(int tick, TickActionBatch actions)
{
foreach (var action in actions.Actions)
{
switch (action)
{
case MoveAxisChanged moveAxisChanged:
CurrentState.GetRequiredPlayer(moveAxisChanged.PlayerId).SetMoveAxis(moveAxisChanged.X, moveAxisChanged.Y);
break;
case AimAxisChanged aimAxisChanged:
CurrentState.GetRequiredPlayer(aimAxisChanged.PlayerId).SetAimAxis(aimAxisChanged.X, aimAxisChanged.Y);
break;
case ButtonChanged buttonChanged:
var player = CurrentState.GetRequiredPlayer(buttonChanged.PlayerId);
var wasPressed = player.IsButtonPressed(buttonChanged.Button);
player.SetButton(buttonChanged.Button, buttonChanged.IsPressed);
if (buttonChanged.Button == InputButton.Jump && buttonChanged.IsPressed && !wasPressed)
player.BufferJump(tick);
break;
case WeaponSlotSelected weaponSlotSelected:
CurrentState.GetRequiredPlayer(weaponSlotSelected.PlayerId).SelectWeaponSlot(weaponSlotSelected.SlotIndex);
break;
default:
throw new NotSupportedException($"Unsupported action type {action.GetType().Name}.");
}
}
}
private void AdvancePlayers(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
var definition = GetPlayerDefinition(player.PlayerId);
if (definition.UsesPlatformerMotion)
AdvancePlatformerPlayer(player, definition, tick, events);
else if (player.MoveAxisX != 0 || player.MoveAxisY != 0)
{
player.Advance();
events.Add(new("PlayerMoved", tick, player.PlayerId));
}
}
}
private void ResolveBounds(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
if (GetPlayerDefinition(player.PlayerId).UsesPlatformerMotion)
continue;
var clamped = m_GameDefinition.Level.WorldBounds.Clamp(player.Position);
if (clamped != player.Position)
{
player.SetPosition(clamped);
events.Add(new("PlayerClamped", tick, player.PlayerId));
}
}
}
private void AdvancePlatformerPlayer(PlayerState player, PlayerDefinition definition, int tick, List<SimulationEvent> events)
{
var previousPosition = player.Position;
var nextPosition = previousPosition;
nextPosition.m_X += definition.MoveSpeedPerTick * player.MoveAxisX;
TryConsumeBufferedJump(player, definition, tick, events);
if (!player.IsGrounded || !player.VerticalVelocity.IsZero())
player.SetVerticalVelocity(player.VerticalVelocity + definition.GravityPerTick);
nextPosition.m_Y += player.VerticalVelocity;
var clampedX = FixPoint16.Clamp(nextPosition.m_X, m_GameDefinition.Level.WorldBounds.Min.m_X, m_GameDefinition.Level.WorldBounds.Max.m_X);
if (clampedX != nextPosition.m_X)
{
nextPosition.m_X = clampedX;
events.Add(new("PlayerClamped", tick, player.PlayerId));
}
ResolvePlatformerVerticalMovement(player, definition, previousPosition, ref nextPosition, tick, events);
player.SetPosition(nextPosition);
if (player.Position != previousPosition)
events.Add(new("PlayerMoved", tick, player.PlayerId));
}
private void ResolvePlatformerVerticalMovement(PlayerState player, PlayerDefinition definition, FixPointVector2 previousPosition, ref FixPointVector2 nextPosition, int tick, List<SimulationEvent> events)
{
if (TryFindLandingY(previousPosition, nextPosition, out var landingY))
{
nextPosition.m_Y = landingY;
player.SetVerticalVelocity(FixPoint16.Zero);
if (!player.IsGrounded)
events.Add(new("PlayerLanded", tick, player.PlayerId));
player.SetGrounded(true, tick);
if (TryConsumeBufferedJump(player, definition, tick, events))
{
nextPosition.m_Y += player.VerticalVelocity;
nextPosition.m_Y = FixPoint16.Max(nextPosition.m_Y, m_GameDefinition.Level.WorldBounds.Min.m_Y);
}
return;
}
var clampedY = FixPoint16.Clamp(nextPosition.m_Y, m_GameDefinition.Level.WorldBounds.Min.m_Y, m_GameDefinition.Level.WorldBounds.Max.m_Y);
if (clampedY != nextPosition.m_Y)
{
nextPosition.m_Y = clampedY;
player.SetVerticalVelocity(FixPoint16.Zero);
events.Add(new("PlayerClamped", tick, player.PlayerId));
}
if (!IsSupported(m_GameDefinition.Level, nextPosition) && player.IsGrounded)
player.LeaveGround(tick);
}
private bool TryConsumeBufferedJump(PlayerState player, PlayerDefinition definition, int tick, List<SimulationEvent> events)
{
if (!player.HasBufferedJump(tick, definition.JumpBufferTicks))
return false;
if (!player.IsGrounded && (player.LastGroundedTick < 0 || tick - player.LastGroundedTick > definition.CoyoteTicks))
return false;
player.SetVerticalVelocity(-definition.JumpVelocityPerTick);
player.SetGrounded(false, tick);
player.ConsumeBufferedJump();
events.Add(new("PlayerJumped", tick, player.PlayerId));
return true;
}
private bool TryFindLandingY(FixPointVector2 previousPosition, FixPointVector2 nextPosition, out FixPoint16 landingY)
{
landingY = default;
var found = false;
var worldFloorY = m_GameDefinition.Level.WorldBounds.Max.m_Y;
if (previousPosition.m_Y <= worldFloorY && nextPosition.m_Y >= worldFloorY)
{
landingY = worldFloorY;
found = true;
}
foreach (var platform in m_GameDefinition.Level.Platforms)
{
var topY = platform.Bounds.Min.m_Y;
if (previousPosition.m_Y > topY || nextPosition.m_Y < topY)
continue;
if (nextPosition.m_X < platform.Bounds.Min.m_X || nextPosition.m_X > platform.Bounds.Max.m_X)
continue;
if (!found || topY < landingY)
{
landingY = topY;
found = true;
}
}
return found;
}
private static bool IsSupported(LevelDefinition levelDefinition, FixPointVector2 position)
{
if (position.m_Y == levelDefinition.WorldBounds.Max.m_Y)
return true;
foreach (var platform in levelDefinition.Platforms)
{
if (position.m_Y == platform.Bounds.Min.m_Y && position.m_X >= platform.Bounds.Min.m_X && position.m_X <= platform.Bounds.Max.m_X)
return true;
}
return false;
}
private PlayerDefinition GetPlayerDefinition(PlayerId playerId)
{
foreach (var player in m_GameDefinition.Players)
{
if (player.PlayerId == playerId)
return player;
}
throw new InvalidOperationException($"Unknown player id {playerId.Value}.");
}
private void ResolveHazards(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
foreach (var hazard in m_GameDefinition.Level.Hazards)
{
if (!hazard.Bounds.Contains(player.Position))
continue;
player.ApplyDamage(hazard.DamagePerTick);
events.Add(new("PlayerDamaged", tick, player.PlayerId));
}
}
}
private void ResolveTriggers(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
foreach (var trigger in m_GameDefinition.Level.Triggers)
{
if (!trigger.Bounds.Contains(player.Position) || !CurrentState.ActivateTrigger(trigger.Id))
continue;
events.Add(new(trigger.Kind, tick, player.PlayerId));
}
}
}
private ulong AdvanceRandom()
{
SIntRandom random = new(CurrentState.RandomState);
random.Next();
return random.Seed;
}
[ExcludeFromCodeCoverage]
private void ValidateRoundTripState(int expectedHash)
{
if (!m_EnableVerification || m_Config.VerificationMode == VerificationMode.None)
return;
var currentBytes = SaveState();
var roundTrip = LoadStateCore(currentBytes, m_GameDefinition, m_Config, false);
var roundTrippedBytes = roundTrip.SaveState();
if (!currentBytes.AsSpan().SequenceEqual(roundTrippedBytes))
throw new SimulationVerificationException("Round-trip serialization changed the saved state payload.");
var roundTripHash = ComputeStateHash(roundTrip.CurrentState);
if (roundTripHash != expectedHash)
throw new SimulationVerificationException("Round-trip serialization changed the state hash.");
}
[ExcludeFromCodeCoverage]
private static void ValidateCloneResult(TickResult? cloneResult, int stateHash, int tick)
{
if (cloneResult is null)
return;
if (cloneResult.StateHash != stateHash)
throw new SimulationVerificationException($"Clone replay diverged at tick {tick}.");
}
public int CurrentTick => CurrentState.Tick;
public SimulationState CurrentState { get; }
public WorldSnapshot PreviousSnapshot { get; private set; }
public WorldSnapshot CurrentSnapshot { get; private set; }
private readonly SimulationConfig m_Config;
private readonly bool m_EnableVerification;
private readonly GameDefinition m_GameDefinition;
}

View File

@@ -1,23 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim;
[ExcludeFromCodeCoverage]
public sealed record SimulationConfig
{
public SimulationConfig(int ticksPerSecond, VerificationMode verificationMode)
{
if (ticksPerSecond <= 0)
throw new ArgumentOutOfRangeException(nameof(ticksPerSecond));
TicksPerSecond = ticksPerSecond;
VerificationMode = verificationMode;
}
public int TicksPerSecond { get; init; }
public VerificationMode VerificationMode { get; init; }
public static SimulationConfig Default { get; } = new(SimulationDefaults.DefaultTicksPerSecond, VerificationMode.None);
}

View File

@@ -1,11 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim;
[ExcludeFromCodeCoverage]
public static class SimulationDefaults
{
public const int DefaultTicksPerSecond = 60;
public const int ReplayFormatVersion = 1;
public const int StateFormatVersion = 1;
}

View File

@@ -1,11 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Verification;
[ExcludeFromCodeCoverage]
public sealed class SimulationVerificationException : Exception
{
public SimulationVerificationException(string message) : base(message)
{
}
}

View File

@@ -1,8 +0,0 @@
namespace SideScrollerGame.Sim.Verification;
public enum VerificationMode
{
None,
RoundTripState,
RoundTripAndStepClone
}

View File

@@ -1,16 +0,0 @@
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Tests;
public sealed class DeterministicMathSmokeTests
{
[Fact]
public void FixPointAverage_UsesSimulationMathTypes()
{
FixPoint16[] values = [new(1), new(2), new(3)];
var average = values.Average();
Assert.Equal(new(2), average);
}
}

View File

@@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Exclude>[SideScrollerGame.Sim]SideScrollerGame.Sim.Math.*,[SideScrollerGame.Sim]SideScrollerGame.Sim.SimulationDefaults</Exclude>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="coverlet.msbuild" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SideScrollerGame.Sim\SideScrollerGame.Sim.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,19 +0,0 @@
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationConfigTests
{
[Fact]
public void Default_UsesRepositoryDefaults()
{
Assert.Equal(SimulationDefaults.DefaultTicksPerSecond, SimulationConfig.Default.TicksPerSecond);
Assert.Equal(VerificationMode.None, SimulationConfig.Default.VerificationMode);
}
[Fact]
public void Constructor_RejectsNonPositiveTicksPerSecond()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new SimulationConfig(0, VerificationMode.None));
}
}

View File

@@ -1,119 +0,0 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Replay;
using SideScrollerGame.Sim.Serialization;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationReplayTests
{
[Fact]
public void SameSeedAndActions_ProduceSameHashes()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
TickActionBatch[] batches =
[
SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)),
SimulationTestFactory.CreateTick(2, new AimAxisChanged(new(1), 3, 4)),
SimulationTestFactory.CreateTick(3, new ButtonChanged(new(1), InputButton.Dash, true))
];
Simulation left = new(definition, config, 42);
Simulation right = new(definition, config, 42);
var leftHashes = ImmutableArray.CreateBuilder<int>();
var rightHashes = ImmutableArray.CreateBuilder<int>();
foreach (var batch in batches)
{
leftHashes.Add(left.Step(batch).StateHash);
rightHashes.Add(right.Step(batch).StateHash);
}
Assert.Equal(leftHashes.ToImmutable(), rightHashes.ToImmutable());
}
[Fact]
public void DifferentSeeds_ProduceDifferentHashes()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
var batch = TickActionBatch.Empty(1);
Simulation left = new(definition, config, 1);
Simulation right = new(definition, config, 2);
Assert.NotEqual(left.Step(batch).StateHash, right.Step(batch).StateHash);
}
[Fact]
public void ReplayRecord_RoundTripsAndReplaysExpectedHashes()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
Simulation simulation = new(definition, config, 9);
ReplayRecorder recorder = new();
TickActionBatch[] batches =
[
SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 2, 0), new AimAxisChanged(new(1), 5, 6)),
SimulationTestFactory.CreateTick(2, new ButtonChanged(new(1), InputButton.FireSecondary, true)),
SimulationTestFactory.CreateTick(3, new WeaponSlotSelected(new(1), 4))
];
foreach (var batch in batches)
{
var result = simulation.Step(batch);
recorder.Append(batch, result);
}
var replay = recorder.Build(definition, config, 9);
var payload = ReplayRecordSerializer.Serialize(replay);
var loadedReplay = ReplayRecordSerializer.Deserialize(payload);
var replayedHashes = ReplayPlayer.Play(loadedReplay, definition, config);
Assert.Equal(loadedReplay.Ticks.Select(static tick => tick.ExpectedStateHash).ToImmutableArray(), replayedHashes);
}
[Fact]
public void ReplayPlayer_RejectsMismatchedDefinition()
{
var config = SimulationTestFactory.CreateConfig();
var definition = SimulationTestFactory.CreateGameDefinition();
ReplayRecord replay = new(123, 9, config.TicksPerSecond, ImmutableArray<RecordedTick>.Empty);
var exception = Assert.Throws<InvalidOperationException>(() => ReplayPlayer.Play(replay, definition, config));
Assert.Contains("content hash", exception.Message.ToLowerInvariant());
}
[Fact]
public void ReplayPlayer_RejectsMismatchedTickRate()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var replay = new ReplayRecorder().Build(definition, SimulationTestFactory.CreateConfig(), 9) with { TicksPerSecond = 30 };
var exception = Assert.Throws<InvalidOperationException>(() => ReplayPlayer.Play(replay, definition, SimulationTestFactory.CreateConfig()));
Assert.Contains("tick rate", exception.Message.ToLowerInvariant());
}
[Fact]
public void ReplayPlayer_RejectsDivergentHashes()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
Simulation simulation = new(definition, config, 55);
ReplayRecorder recorder = new();
var batch = SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0));
var result = simulation.Step(batch);
recorder.Append(batch, result);
var replay = recorder.Build(definition, config, 55);
var divergentReplay = replay with { Ticks = ImmutableArray.Create(replay.Ticks[0] with { ExpectedStateHash = replay.Ticks[0].ExpectedStateHash + 1 }) };
var exception = Assert.Throws<InvalidOperationException>(() => ReplayPlayer.Play(divergentReplay, definition, config));
Assert.Contains("diverged", exception.Message.ToLowerInvariant());
}
}

View File

@@ -1,45 +0,0 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationSerializationTests
{
[Fact]
public void SaveStateLoadState_PreservesStateAndNextStepHash()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 20)), triggers: ImmutableArray.Create(new TriggerDefinition("checkpoint_a", new(new(11, 17), new(12, 18)), "TriggerActivated")), players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10, true, FixPoint16.One, FixPoint16.One, new(3), 1, 1)));
var config = SimulationTestFactory.CreateConfig();
Simulation original = new(definition, config, 17);
original.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 1), new ButtonChanged(new(1), InputButton.Jump, true)));
var bytes = original.SaveState();
var loaded = Simulation.LoadState(bytes, definition, config);
Assert.Equal(original.CurrentTick, loaded.CurrentTick);
Assert.Equal(original.CurrentSnapshot.StateHash, loaded.CurrentSnapshot.StateHash);
Assert.Equal(original.CurrentState.GetRequiredPlayer(new(1)).Health, loaded.CurrentState.GetRequiredPlayer(new(1)).Health);
Assert.Equal(original.CurrentState.ActivatedTriggerIds, loaded.CurrentState.ActivatedTriggerIds);
Assert.Equal(original.CurrentState.GetRequiredPlayer(new(1)).VerticalVelocity, loaded.CurrentState.GetRequiredPlayer(new(1)).VerticalVelocity);
Assert.Equal(original.CurrentState.GetRequiredPlayer(new(1)).IsGrounded, loaded.CurrentState.GetRequiredPlayer(new(1)).IsGrounded);
var nextBatch = SimulationTestFactory.CreateTick(2, new MoveAxisChanged(new(1), 0, 0), new ButtonChanged(new(1), InputButton.FirePrimary, true));
var originalHash = original.Step(nextBatch).StateHash;
var loadedHash = loaded.Step(nextBatch).StateHash;
Assert.Equal(originalHash, loadedHash);
}
[Fact]
public void Constructor_NormalizesZeroSeed()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 0);
simulation.Step(TickActionBatch.Empty(1));
Assert.NotEqual(0UL, simulation.CurrentState.RandomState);
}
}

View File

@@ -1,91 +0,0 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
using SideScrollerGame.Sim.Runtime;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationStateTests
{
[Fact]
public void PlayerState_CloneAndButtonReleasePreserveIndependentState()
{
PlayerState player = new(new(1), new(3, 4), 1, 2, 5, 6, 7, 0, 9, FixPoint16.One, true, 3, 4);
player.SetButton(InputButton.Dash, true);
player.ApplyDamage(4);
player.BufferJump(6);
var clone = player.Clone();
player.SetButton(InputButton.Dash, false);
player.SetPosition(new(8, 9));
player.SetVerticalVelocity(new(5));
Assert.NotEqual(player.ButtonMask, clone.ButtonMask);
Assert.Equal(7, clone.SelectedWeaponSlot);
Assert.Equal(3, clone.Position.m_X.ToIntRound());
Assert.Equal(4, clone.Position.m_Y.ToIntRound());
Assert.Equal(5, clone.Health);
Assert.Equal(1, clone.VerticalVelocity.ToIntRound());
Assert.True(clone.IsGrounded);
Assert.Equal(3, clone.LastGroundedTick);
Assert.Equal(6, clone.BufferedJumpTick);
}
[Fact]
public void SimulationState_CloneCreatesDeepCopy()
{
SimulationState original = new(4, 9, 123UL, 456UL, ImmutableArray.Create(new PlayerState(new(1), new(1, 2), 3, 4, 5, 6, 7, 8, 9, new(2), true, 4, -1)), ImmutableHashSet<string>.Empty.Add("checkpoint_a"));
var clone = original.Clone();
original.GetRequiredPlayer(new(1)).SetMoveAxis(9, 9);
original.ActivateTrigger("checkpoint_b");
Assert.Equal(4, clone.Tick);
Assert.Equal(9, clone.Seed);
Assert.Equal((ulong)123, clone.RandomState);
Assert.Equal((ulong)456, clone.LastRandomValue);
Assert.Equal(3, clone.GetRequiredPlayer(new(1)).MoveAxisX);
Assert.Equal(2, clone.GetRequiredPlayer(new(1)).VerticalVelocity.ToIntRound());
Assert.DoesNotContain("checkpoint_b", clone.ActivatedTriggerIds);
}
[Fact]
public void SimulationState_AcceptsDefaultPlayerArray()
{
SimulationState state = new(0, 1, 1UL, 0UL, default, ImmutableHashSet<string>.Empty);
Assert.Empty(state.Players);
Assert.Empty(state.ActivatedTriggerIds);
}
[Fact]
public void SimulationState_AcceptsDefaultTriggerSet()
{
SimulationState state = new(0, 1, 1UL, 0UL, ImmutableArray<PlayerState>.Empty, default!);
Assert.Empty(state.ActivatedTriggerIds);
}
[Fact]
public void ActivateTrigger_ReturnsFalseWhenRepeated()
{
SimulationState state = new(0, 1, 1UL, 0UL, ImmutableArray<PlayerState>.Empty, ImmutableHashSet<string>.Empty);
Assert.True(state.ActivateTrigger("checkpoint_a"));
Assert.False(state.ActivateTrigger("checkpoint_a"));
}
[Fact]
public void PlayerState_BufferedJumpExpiresOutsideWindow()
{
PlayerState player = new(new(1), new(0, 0), 0, 0, 0, 0, 0, 0, 10, FixPoint16.Zero, false, -1, -1);
player.BufferJump(3);
Assert.True(player.HasBufferedJump(4, 1));
Assert.False(player.HasBufferedJump(5, 1));
player.ConsumeBufferedJump();
Assert.False(player.HasBufferedJump(5, 5));
}
}

View File

@@ -1,417 +0,0 @@
using System.Collections.Immutable;
using System.Text;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationStepTests
{
private sealed record UnsupportedAction : SimulationAction;
private static byte[] CreateStatePayload(int playerId, int positionX, int positionY, bool isGrounded, int lastGroundedTick = -1)
{
return Encoding.UTF8.GetBytes($$"""
{"Version":{{SimulationDefaults.StateFormatVersion}},"Tick":0,"Seed":7,"RandomState":7,"LastRandomValue":0,"Players":[{"PlayerId":{{playerId}},"PositionX":{{positionX * 65536}},"PositionY":{{positionY * 65536}},"MoveAxisX":0,"MoveAxisY":0,"AimAxisX":0,"AimAxisY":0,"SelectedWeaponSlot":0,"ButtonMask":0,"Health":10,"VerticalVelocity":0,"IsGrounded":{{isGrounded.ToString().ToLowerInvariant()}},"LastGroundedTick":{{lastGroundedTick}},"BufferedJumpTick":-1}],"ActivatedTriggerIds":[]}
""");
}
[Fact]
public void Step_AdvancesTickSnapshotsAndMovement()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 2, -1), new AimAxisChanged(new(1), 30, 40), new ButtonChanged(new(1), InputButton.Jump, true), new WeaponSlotSelected(new(1), 3)));
Assert.Equal(1, simulation.CurrentTick);
Assert.Equal(0, simulation.PreviousSnapshot.Tick);
Assert.Equal(0, result.PreviousSnapshot.Tick);
Assert.Equal(1, result.CurrentSnapshot.Tick);
Assert.Single(result.Events);
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(12, player.Position.m_X.ToIntRound());
Assert.Equal(19, player.Position.m_Y.ToIntRound());
Assert.Equal(30, player.AimAxisX);
Assert.Equal(40, player.AimAxisY);
Assert.Equal(3, player.SelectedWeaponSlot);
Assert.Equal(10, player.Health);
Assert.NotEqual(0, player.ButtonMask);
Assert.Equal(result.StateHash, simulation.CurrentSnapshot.StateHash);
Assert.NotEqual(0UL, simulation.CurrentSnapshot.LastRandomValue);
}
[Fact]
public void Step_ClampsPlayerToWorldBounds()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(11, 20))), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 5, 0)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(11, player.Position.m_X.ToIntRound());
Assert.Contains(result.Events, static e => e.Kind == "PlayerClamped");
}
[Fact]
public void Step_AppliesHazardDamage()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(hazards: ImmutableArray.Create(new HazardDefinition("lava", new(new(11, 20), new(12, 21)), 3))), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(7, player.Health);
Assert.Contains(result.Events, static e => e.Kind == "PlayerDamaged");
}
[Fact]
public void Step_IgnoresHazardsWhenPlayerIsOutside()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(hazards: ImmutableArray.Create(new HazardDefinition("lava", new(new(50, 50), new(60, 60)), 3))), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
Assert.Equal(10, simulation.CurrentState.GetRequiredPlayer(new(1)).Health);
Assert.DoesNotContain(result.Events, static e => e.Kind == "PlayerDamaged");
}
[Fact]
public void Step_ActivatesTriggerOnlyOnce()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(triggers: ImmutableArray.Create(new TriggerDefinition("checkpoint_a", new(new(11, 20), new(12, 21)), "TriggerActivated"))), SimulationTestFactory.CreateConfig(), 7);
var first = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
var second = simulation.Step(SimulationTestFactory.CreateTick(2, new MoveAxisChanged(new(1), 0, 0)));
Assert.Contains(first.Events, static e => e.Kind == "TriggerActivated");
Assert.DoesNotContain(second.Events, static e => e.Kind == "TriggerActivated");
Assert.Contains("checkpoint_a", simulation.CurrentState.ActivatedTriggerIds);
}
[Fact]
public void Step_IgnoresTriggersWhenPlayerIsOutside()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(triggers: ImmutableArray.Create(new TriggerDefinition("checkpoint_a", new(new(50, 50), new(60, 60)), "TriggerActivated"))), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
Assert.DoesNotContain(result.Events, static e => e.Kind == "TriggerActivated");
Assert.Empty(simulation.CurrentState.ActivatedTriggerIds);
}
[Fact]
public void Step_RejectsUnexpectedTickNumbers()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7);
var exception = Assert.Throws<InvalidOperationException>(() => simulation.Step(TickActionBatch.Empty(2)));
Assert.Contains("Expected tick 1", exception.Message);
}
[Fact]
public void Step_RejectsUnknownPlayers()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7);
var exception = Assert.Throws<InvalidOperationException>(() => simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(99), 1, 0))));
Assert.Contains("Unknown player id 99", exception.Message);
}
[Fact]
public void Step_RejectsUnsupportedActions()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7);
var exception = Assert.Throws<NotSupportedException>(() => simulation.Step(SimulationTestFactory.CreateTick(1, new UnsupportedAction())));
Assert.Contains("Unsupported action type", exception.Message);
}
[Fact]
public void Constructor_RejectsDuplicatePlayers()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new(new(1), new(0, 0), 10), new PlayerDefinition(new(1), new(2, 3), 10)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("Duplicate player id 1", exception.Message);
}
[Fact]
public void Constructor_RejectsSpawnOutsideBounds()
{
GameDefinition definition = new(new(new(new(0, 0), new(5, 5)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("spawn must start inside world bounds", exception.Message);
}
[Fact]
public void Constructor_RejectsNonPositiveHealth()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 0)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("positive health", exception.Message);
}
[Fact]
public void Constructor_RejectsNullDefinition()
{
var exception = Assert.Throws<ArgumentNullException>(() => new Simulation(null!, SimulationTestFactory.CreateConfig(), 1));
Assert.Equal("gameDefinition", exception.ParamName);
}
[Fact]
public void Constructor_RejectsNullConfig()
{
var exception = Assert.Throws<ArgumentNullException>(() => new Simulation(SimulationTestFactory.CreateGameDefinition(), null!, 1));
Assert.Equal("config", exception.ParamName);
}
[Fact]
public void LoadState_RejectsNullDefinition()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
Simulation simulation = new(definition, config, 2);
var bytes = simulation.SaveState();
var exception = Assert.Throws<ArgumentNullException>(() => Simulation.LoadState(bytes, null!, config));
Assert.Equal("gameDefinition", exception.ParamName);
}
[Fact]
public void Step_PlatformerJumpUsesBufferedInputAndProducesVerticalVelocity()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 20)), players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 1), new ButtonChanged(new(1), InputButton.Jump, true)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.False(player.IsGrounded);
Assert.Equal(12, player.Position.m_X.ToIntRound());
Assert.Equal(18, player.Position.m_Y.ToIntRound());
Assert.Equal(-2, player.VerticalVelocity.ToIntRound());
Assert.Contains(result.Events, static e => e.Kind == "PlayerJumped");
Assert.DoesNotContain(result.Events, static e => e.Kind == "PlayerClamped");
}
[Fact]
public void Step_PlatformerUsesCoyoteTimeAfterLeavingPlatform()
{
var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(10, 20), new(12, 22))));
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
var first = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 2, 0)));
var second = simulation.Step(SimulationTestFactory.CreateTick(2, new MoveAxisChanged(new(1), 0, 0), new ButtonChanged(new(1), InputButton.Jump, true)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.DoesNotContain(first.Events, static e => e.Kind == "PlayerJumped");
Assert.Contains(second.Events, static e => e.Kind == "PlayerJumped");
Assert.False(player.IsGrounded);
Assert.Equal(14, player.Position.m_X.ToIntRound());
Assert.Equal(18, player.Position.m_Y.ToIntRound());
}
[Fact]
public void Step_PlatformerConsumesJumpBufferOnLanding()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 20)), players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 18), 10, true, FixPoint16.One, new(2), new(3), 0, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new ButtonChanged(new(1), InputButton.Jump, true)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.False(player.IsGrounded);
Assert.Equal(17, player.Position.m_Y.ToIntRound());
Assert.Equal(-3, player.VerticalVelocity.ToIntRound());
Assert.Contains(result.Events, static e => e.Kind == "PlayerLanded");
Assert.Contains(result.Events, static e => e.Kind == "PlayerJumped");
}
[Fact]
public void Step_PlatformerClampsHorizontalMovementAtWorldEdge()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(11, 20)), players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
Assert.Equal(11, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_X.ToIntRound());
Assert.Contains(result.Events, static e => e.Kind == "PlayerClamped");
}
[Fact]
public void Step_PlatformerClampsJumpAtWorldCeiling()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(20, 2)), players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 2), 10, true, FixPoint16.Zero, FixPoint16.Zero, new(4), 0, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new ButtonChanged(new(1), InputButton.Jump, true)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(0, player.Position.m_Y.ToIntRound());
Assert.Equal(0, player.VerticalVelocity.ToIntRound());
Assert.Contains(result.Events, static e => e.Kind == "PlayerClamped");
}
[Fact]
public void Step_PlatformerLandsOnHighestCrossedPlatform()
{
var platforms = ImmutableArray.Create(new("lower", new(new(8, 20), new(12, 22))), new SolidPlatformDefinition("upper", new(new(8, 15), new(12, 17))));
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 10), 10, true, FixPoint16.Zero, new(12), new(3), 1, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(15, player.Position.m_Y.ToIntRound());
Assert.True(player.IsGrounded);
Assert.Contains(result.Events, static e => e.Kind == "PlayerLanded");
}
[Fact]
public void Step_PlatformerIgnoresPlatformWhenStillAboveTop()
{
var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(8, 15), new(12, 17))));
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 10), 10, true, FixPoint16.Zero, FixPoint16.Zero, new(3), 1, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
simulation.Step(SimulationTestFactory.CreateTick(1));
Assert.Equal(10, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_Y.ToIntRound());
}
[Fact]
public void Step_PlatformerIgnoresPlatformWhenAlreadyBelowTop()
{
var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(8, 15), new(12, 17))));
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10, true, FixPoint16.Zero, FixPoint16.Zero, new(3), 1, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
simulation.Step(SimulationTestFactory.CreateTick(1));
Assert.Equal(20, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_Y.ToIntRound());
}
[Fact]
public void Step_PlatformerIgnoresPlatformWhenFallingOutsideRightEdge()
{
var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(8, 15), new(12, 17))));
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(14, 10), 10, true, FixPoint16.Zero, new(12), new(3), 1, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
simulation.Step(SimulationTestFactory.CreateTick(1));
Assert.Equal(22, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_Y.ToIntRound());
}
[Fact]
public void Step_PlatformerIgnoresPlatformWhenFallingOutsideLeftEdge()
{
var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(8, 15), new(12, 17))));
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(6, 10), 10, true, FixPoint16.Zero, new(12), new(3), 1, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
simulation.Step(SimulationTestFactory.CreateTick(1));
Assert.Equal(22, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_Y.ToIntRound());
}
[Fact]
public void Step_LoadedPlatformerStateCanRegainGroundedFlagFromSupport()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(20, 20)), players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20)));
var simulation = Simulation.LoadState(CreateStatePayload(1, 10, 20, false), definition, SimulationTestFactory.CreateConfig());
simulation.Step(SimulationTestFactory.CreateTick(1));
Assert.True(simulation.CurrentState.GetRequiredPlayer(new(1)).IsGrounded);
}
[Fact]
public void Step_LoadedStateBelowFloorClampsBackIntoSupport()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(20, 20)), players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20)));
var simulation = Simulation.LoadState(CreateStatePayload(1, 10, 25, false), definition, SimulationTestFactory.CreateConfig());
simulation.Step(SimulationTestFactory.CreateTick(1));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(20, player.Position.m_Y.ToIntRound());
Assert.False(player.IsGrounded);
}
[Fact]
public void Step_LoadedStateRejectsPlayersMissingFromDefinition()
{
var definition = SimulationTestFactory.CreateGameDefinition(players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20)));
var simulation = Simulation.LoadState(CreateStatePayload(99, 10, 20, true, 0), definition, SimulationTestFactory.CreateConfig());
var exception = Assert.Throws<InvalidOperationException>(() => simulation.Step(SimulationTestFactory.CreateTick(1)));
Assert.Contains("Unknown player id 99", exception.Message);
}
[Fact]
public void Constructor_RejectsNegativeJumpBufferTicks()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, FixPoint16.One, FixPoint16.One, new(3), 1, -1)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("jump buffer ticks", exception.Message);
}
[Fact]
public void Constructor_RejectsNegativeMoveSpeed()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, -FixPoint16.One, FixPoint16.One, new(3), 1, 1)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("move speed", exception.Message);
}
[Fact]
public void Constructor_RejectsNegativeGravity()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, FixPoint16.One, -FixPoint16.One, new(3), 1, 1)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("gravity", exception.Message);
}
[Fact]
public void Constructor_RejectsNegativeJumpVelocity()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, FixPoint16.One, FixPoint16.One, -FixPoint16.One, 1, 1)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("jump velocity", exception.Message);
}
[Fact]
public void Constructor_RejectsNegativeCoyoteTicks()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, FixPoint16.One, FixPoint16.One, new(3), -1, 1)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("coyote ticks", exception.Message);
}
}

View File

@@ -1,30 +0,0 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim.Tests;
internal static class SimulationTestFactory
{
public static GameDefinition CreateGameDefinition(AxisAlignedBounds? worldBounds = null, ImmutableArray<HazardDefinition> hazards = default, ImmutableArray<TriggerDefinition> triggers = default, ImmutableArray<SolidPlatformDefinition> platforms = default, ImmutableArray<PlayerDefinition> players = default)
{
return new(new(worldBounds ?? new(new(0, 0), new(100, 100)), hazards, triggers, platforms), players.IsDefault ? ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10)) : players);
}
public static PlayerDefinition CreatePlatformerPlayerDefinition(PlayerId playerId, int spawnX, int spawnY)
{
return new(playerId, new(spawnX, spawnY), 10, true, new(2), FixPoint16.One, new(3), 1, 1);
}
public static SimulationConfig CreateConfig(VerificationMode verificationMode = VerificationMode.None)
{
return new(SimulationDefaults.DefaultTicksPerSecond, verificationMode);
}
public static TickActionBatch CreateTick(int tick, params SimulationAction[] actions)
{
return new(tick, ImmutableArray.Create(actions));
}
}

View File

@@ -1,27 +0,0 @@
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationVerificationTests
{
[Fact]
public void RoundTripStateMode_AllowsStepping()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(VerificationMode.RoundTripState), 22);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
Assert.Equal(1, result.CurrentSnapshot.Tick);
}
[Fact]
public void RoundTripAndStepCloneMode_AllowsStepping()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(VerificationMode.RoundTripAndStepClone), 23);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new ButtonChanged(new(1), InputButton.Jump, true)));
Assert.Equal(1, result.CurrentSnapshot.Tick);
}
}