CC Package and Physics

This commit is contained in:
2026-06-02 08:56:26 -07:00
parent a5af81c8a8
commit 2ee30c01fd
37 changed files with 1295 additions and 142 deletions
+65
View File
@@ -14,6 +14,8 @@ GameObject:
- component: {fileID: 8290370249712404227}
- component: {fileID: 304484164735584996}
- component: {fileID: 1666753161128106451}
- component: {fileID: 3388430386408803303}
- component: {fileID: 2040450355011915060}
m_Layer: 0
m_Name: Player
m_TagString: Untagged
@@ -147,3 +149,66 @@ MonoBehaviour:
Importance: 1
MaxSendRate: 0
prefabId:
--- !u!136 &3388430386408803303
CapsuleCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2218851646297572645}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 2
m_Radius: 0.5
m_Height: 2
m_Direction: 1
m_Center: {x: 0, y: 0, z: 0}
--- !u!114 &2040450355011915060
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2218851646297572645}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e344ebd143cbf68439540a537e6ba4e9, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.PlayerCharacterAuthoring
GroundedMovementSharpness: 15
CharacterProperties:
CustomPhysicsBodyTags:
Tag00: 0
Tag01: 0
Tag02: 0
Tag03: 0
Tag04: 0
Tag05: 0
Tag06: 0
Tag07: 0
InterpolatePosition: 1
InterpolateRotation: 0
EvaluateGrounding: 1
SnapToGround: 1
GroundSnappingDistance: 0.5
EnhancedGroundPrecision: 0
MaxGroundedSlopeAngle: 60
DetectMovementCollisions: 1
DecollideFromOverlaps: 1
ProjectVelocityOnInitialOverlaps: 0
MaxContinuousCollisionsIterations: 8
MaxOverlapDecollisionIterations: 2
DiscardMovementWhenExceedMaxIterations: 1
KillVelocityWhenExceedMaxIterations: 1
DetectObstructionsForParentBodyMovement: 0
SimulateDynamicBody: 1
Mass: 1
@@ -0,0 +1,56 @@
using ProjectM.Simulation;
using Unity.CharacterController;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the kinematic-character half of the player ghost (M5b: Unity Character Controller). Added
/// to the SAME prefab GameObject as <c>PlayerAuthoring</c> + the GhostAuthoringComponent (multiple bakers
/// per GameObject is supported; both resolve the same Entity). The baker calls
/// <see cref="KinematicCharacterUtilities.BakeCharacter"/>, which adds the CC runtime components/buffers
/// (KinematicCharacterProperties, KinematicCharacterBody, StoredKinematicCharacterData, the four CC
/// buffers, kinematic PhysicsVelocity/PhysicsMass, PhysicsGravityFactor, CharacterInterpolation) and bakes
/// the GameObject's CapsuleCollider into a PhysicsCollider. We then add our
/// <see cref="CharacterComponent"/> + <see cref="CharacterControl"/>.
/// <para>
/// IMPORTANT: BakeCharacter aborts (logs an error, adds nothing) if the GameObject has a Rigidbody and
/// requires uniform (1,1,1) scale — so the M5 Rigidbody MUST be removed from the prefab (the M5
/// CapsuleCollider stays — it is what gets baked into the character's PhysicsCollider).
/// </para>
/// </summary>
[DisallowMultipleComponent]
public class PlayerCharacterAuthoring : MonoBehaviour
{
[Tooltip("Sharpness of ground velocity smoothing (higher = snappier).")]
public float GroundedMovementSharpness = 15f;
public AuthoringKinematicCharacterProperties CharacterProperties = AuthoringKinematicCharacterProperties.GetDefault();
private class PlayerCharacterBaker : Baker<PlayerCharacterAuthoring>
{
public override void Bake(PlayerCharacterAuthoring authoring)
{
// Top-down planar character: no gravity (handled in the processor), stay on the plane.
var props = authoring.CharacterProperties;
props.SnapToGround = false; // no floor entity to snap to; planar
props.EvaluateGrounding = true; // floor/obstacle contact still reads as grounded
props.InterpolatePosition = true; // smooth fixed-step position for presentation
props.InterpolateRotation = false; // rotation owned by PlayerAimSystem
props.SimulateDynamicBody = false; // players don't physically shove each other (keep simple)
KinematicCharacterUtilities.BakeCharacter(this, authoring.gameObject, props);
var entity = GetEntity(TransformUsageFlags.Dynamic | TransformUsageFlags.WorldSpace);
AddComponent(entity, new CharacterComponent
{
GroundedMovementSharpness = authoring.GroundedMovementSharpness,
StepAndSlopeHandling = BasicStepAndSlopeHandlingParameters.GetDefault(),
});
AddComponent(entity, new CharacterControl());
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e344ebd143cbf68439540a537e6ba4e9
@@ -7,7 +7,9 @@
"Unity.Entities.Hybrid",
"Unity.Collections",
"Unity.Mathematics",
"Unity.NetCode"
"Unity.NetCode",
"Unity.Physics",
"Unity.CharacterController"
],
"includePlatforms": [],
"excludePlatforms": [],
@@ -10,7 +10,7 @@ namespace ProjectM.Simulation
/// AbilityRef / CharacterStatsRef) with its replicated StatModifier buffer into the
/// EffectiveAbilityStats / EffectiveCharacterStats components - every predicted tick, on both worlds.
///
/// Runs at the head of the predicted group (UpdateBefore PlayerAimSystem and PlayerMoveSystem;
/// Runs at the head of the predicted group (UpdateBefore PlayerAimSystem;
/// AbilityFireSystem runs after PlayerAimSystem, so it sees fresh values too). Recompute is
/// unconditional every tick: it is a pure function of (blob base + replicated buffer), both of which
/// are restored on rollback, so predicted and server results always agree. A dirty-flag / change
@@ -19,7 +19,6 @@ namespace ProjectM.Simulation
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateBefore(typeof(PlayerAimSystem))]
[UpdateBefore(typeof(PlayerMoveSystem))]
[BurstCompile]
public partial struct StatRecomputeSystem : ISystem
{
@@ -0,0 +1,48 @@
using System;
using Unity.CharacterController;
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Tunables for the top-down kinematic character (M5b: Unity Character Controller package). Twin-stick /
/// planar: NO jump, NO air control, NO gravity, NO view pitch. The per-tick target speed comes from the
/// data-driven <see cref="EffectiveCharacterStats.MoveSpeed"/> via <see cref="PlayerControlSystem"/>; this
/// component only carries the smoothing sharpness + step/slope handling the CC processor needs. Authored
/// on the player prefab by <c>PlayerCharacterAuthoring</c>. Not a ghost — movement replicates through the
/// owner-predicted LocalTransform (re-simulated on the owner, interpolated on remotes).
/// </summary>
[Serializable]
public struct CharacterComponent : IComponentData
{
/// <summary>How quickly RelativeVelocity is lerped toward the target velocity on the ground.</summary>
public float GroundedMovementSharpness;
/// <summary>Step/slope handling params (defaults; planar ground so mostly inert).</summary>
public BasicStepAndSlopeHandlingParameters StepAndSlopeHandling;
public static CharacterComponent GetDefault()
{
return new CharacterComponent
{
GroundedMovementSharpness = 15f,
StepAndSlopeHandling = BasicStepAndSlopeHandlingParameters.GetDefault(),
};
}
}
/// <summary>
/// Per-tick, NON-replicated control for the CC processor, derived each predicted tick from the replicated
/// <see cref="PlayerInput"/> by <see cref="PlayerControlSystem"/>. Derived (not authored, not a ghost): a
/// pure function of replicated input + recomputed stats, so it reproduces identically on server, owning
/// client, and across rollback re-simulation.
/// </summary>
[Serializable]
public struct CharacterControl : IComponentData
{
/// <summary>World-space desired velocity (already scaled by MoveSpeed, Y == 0). The processor lerps
/// RelativeVelocity toward this each tick.</summary>
public float3 MoveVelocity;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 69a9d5a5573f2804d987602849f43554
@@ -0,0 +1,24 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, Burst-friendly math for top-down character control, factored out for EditMode unit testing
/// (no World/system needed). Maps a twin-stick move axis + speed to a planar world velocity.
/// </summary>
public static class CharacterControlMath
{
/// <summary>
/// Twin-stick move (x,y) on the XZ plane scaled by <paramref name="speed"/>. Input longer than unit
/// length is clamped (so diagonals aren't faster than cardinals); shorter is left proportional
/// (analog sticks). Returns a world velocity with Y == 0.
/// </summary>
public static float3 DesiredMovement(float2 move, float speed)
{
float lenSq = math.lengthsq(move);
if (lenSq > 1f)
move = math.normalize(move);
return new float3(move.x, 0f, move.y) * speed;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8ada5ffb04316ba48851d44f5b3340f2
@@ -0,0 +1,35 @@
using System.Collections.Generic;
using Unity.CharacterController;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Ghost-variant registration for the kinematic character (M5b). <see cref="CharacterInterpolation"/> is
/// presentation-only fixed-step smoothing that must exist ONLY on predicted clients: on the server it
/// would interfere with LocalToWorld, and on interpolated remote ghosts it would double up with netcode's
/// own snapshot interpolation. <c>BakeCharacter</c> adds it to every prefab version, so we force it
/// predicted-client-only here.
/// <para>
/// We deliberately do NOT override the default variants for <c>LocalTransform</c> or
/// <c>PhysicsVelocity</c> (the CC sample's DontSerializeVariant on LocalTransform is global and would
/// break the non-character ghosts in this project — projectiles, dummies, pickups — which rely on stock
/// LocalTransform replication). The character's position therefore replicates via the normal
/// owner-predicted LocalTransform path, like every other ghost.
/// </para>
/// </summary>
public sealed partial class CharacterGhostVariantsSystem : DefaultVariantSystemBase
{
protected override void RegisterDefaultVariants(Dictionary<ComponentType, Rule> defaultVariants)
{
defaultVariants.Add(typeof(CharacterInterpolation), Rule.ForAll(typeof(CharacterInterpolation_GhostVariant)));
}
}
[GhostComponentVariation(typeof(CharacterInterpolation))]
[GhostComponent(PrefabType = GhostPrefabType.PredictedClient)]
public struct CharacterInterpolation_GhostVariant
{
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6383b28a0d1f44649b514ece99ee4915
@@ -0,0 +1,104 @@
using Unity.Burst;
using Unity.Burst.Intrinsics;
using Unity.CharacterController;
using Unity.Entities;
using Unity.Physics;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>
/// Fixed-step kinematic character update. Runs in <see cref="KinematicCharacterPhysicsUpdateGroup"/>
/// (which sits after the physics build); under Netcode-for-Entities the physics groups are relocated into
/// <see cref="Unity.NetCode.PredictedFixedStepSimulationSystemGroup"/>, so the character integrates inside
/// the predicted loop — deterministic, rollback-safe, identical on server + owning client. Builds a
/// <see cref="KinematicCharacterDataAccess"/> per entity and runs the processor's PhysicsUpdate. Filtered
/// to <see cref="Simulate"/> so only predicted ghosts step.
/// </summary>
[UpdateInGroup(typeof(KinematicCharacterPhysicsUpdateGroup))]
[BurstCompile]
public partial struct CharacterPhysicsUpdateSystem : ISystem
{
EntityQuery m_CharacterQuery;
CharacterUpdateContext m_Context;
KinematicCharacterUpdateContext m_BaseContext;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_CharacterQuery = KinematicCharacterUtilities.GetBaseCharacterQueryBuilder()
.WithAll<CharacterComponent, CharacterControl>()
.Build(ref state);
m_Context = new CharacterUpdateContext();
m_Context.OnSystemCreate(ref state);
m_BaseContext = new KinematicCharacterUpdateContext();
m_BaseContext.OnSystemCreate(ref state);
state.RequireForUpdate(m_CharacterQuery);
state.RequireForUpdate<PhysicsWorldSingleton>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
m_Context.OnSystemUpdate(ref state);
m_BaseContext.OnSystemUpdate(ref state, SystemAPI.Time, SystemAPI.GetSingleton<PhysicsWorldSingleton>());
var job = new CharacterPhysicsUpdateJob
{
Context = m_Context,
BaseContext = m_BaseContext,
};
state.Dependency = job.Schedule(state.Dependency);
}
[BurstCompile]
[WithAll(typeof(Simulate))]
public partial struct CharacterPhysicsUpdateJob : IJobEntity, IJobEntityChunkBeginEnd
{
public CharacterUpdateContext Context;
public KinematicCharacterUpdateContext BaseContext;
void Execute(
Entity entity,
RefRW<LocalTransform> localTransform,
RefRW<KinematicCharacterProperties> characterProperties,
RefRW<KinematicCharacterBody> characterBody,
RefRW<PhysicsCollider> physicsCollider,
RefRW<CharacterComponent> characterComponent,
RefRW<CharacterControl> characterControl,
DynamicBuffer<KinematicCharacterHit> characterHitsBuffer,
DynamicBuffer<StatefulKinematicCharacterHit> statefulHitsBuffer,
DynamicBuffer<KinematicCharacterDeferredImpulse> deferredImpulsesBuffer,
DynamicBuffer<KinematicVelocityProjectionHit> velocityProjectionHits)
{
var processor = new CharacterProcessor
{
CharacterDataAccess = new KinematicCharacterDataAccess(
entity,
localTransform,
characterProperties,
characterBody,
physicsCollider,
characterHitsBuffer,
statefulHitsBuffer,
deferredImpulsesBuffer,
velocityProjectionHits),
CharacterComponent = characterComponent,
CharacterControl = characterControl,
};
processor.PhysicsUpdate(ref Context, ref BaseContext);
}
public bool OnChunkBegin(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask)
{
BaseContext.EnsureCreationOfTmpCollections();
return true;
}
public void OnChunkEnd(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask, bool chunkWasExecuted) { }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 70d14da3eeeaec24896586d71fd22fdc
@@ -0,0 +1,213 @@
using Unity.CharacterController;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Physics;
namespace ProjectM.Simulation
{
/// <summary>
/// Per-update "global" data the character processor needs (lookups, singletons). Empty for the minimal
/// top-down character — kept so the CC update signature is satisfied and future lookups have a home.
/// </summary>
public struct CharacterUpdateContext
{
public void OnSystemCreate(ref SystemState state) { }
public void OnSystemUpdate(ref SystemState state) { }
}
/// <summary>
/// Top-down kinematic character processor (CC 1.4.2 pattern: <see cref="IKinematicCharacterProcessor{T}"/>
/// + a <see cref="KinematicCharacterDataAccess"/> built in the job, driving the static
/// <see cref="KinematicCharacterUtilities"/> Update_* sequence). Stripped of all FPS features: no jump,
/// no air movement, no gravity, no view. <see cref="PhysicsUpdate"/> runs the canonical CC update
/// sequence; <see cref="HandleVelocityControl"/> lerps RelativeVelocity toward the desired planar
/// velocity <see cref="PlayerControlSystem"/> wrote. Rotation/facing is owned by <see cref="PlayerAimSystem"/>.
/// </summary>
public struct CharacterProcessor : IKinematicCharacterProcessor<CharacterUpdateContext>
{
public KinematicCharacterDataAccess CharacterDataAccess;
public RefRW<CharacterComponent> CharacterComponent;
public RefRW<CharacterControl> CharacterControl;
public void PhysicsUpdate(ref CharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext)
{
ref CharacterComponent characterComponent = ref CharacterComponent.ValueRW;
ref KinematicCharacterBody characterBody = ref CharacterDataAccess.CharacterBody.ValueRW;
ref float3 characterPosition = ref CharacterDataAccess.LocalTransform.ValueRW.Position;
KinematicCharacterUtilities.Update_Initialize(
in this, ref context, ref baseContext,
ref characterBody,
CharacterDataAccess.CharacterHitsBuffer,
CharacterDataAccess.DeferredImpulsesBuffer,
CharacterDataAccess.VelocityProjectionHits,
baseContext.Time.DeltaTime);
KinematicCharacterUtilities.Update_ParentMovement(
in this, ref context, ref baseContext,
CharacterDataAccess.CharacterEntity,
ref characterBody,
CharacterDataAccess.CharacterProperties.ValueRO,
CharacterDataAccess.PhysicsCollider.ValueRO,
CharacterDataAccess.LocalTransform.ValueRO,
ref characterPosition,
characterBody.WasGroundedBeforeCharacterUpdate);
KinematicCharacterUtilities.Update_Grounding(
in this, ref context, ref baseContext,
ref characterBody,
CharacterDataAccess.CharacterEntity,
CharacterDataAccess.CharacterProperties.ValueRO,
CharacterDataAccess.PhysicsCollider.ValueRO,
CharacterDataAccess.LocalTransform.ValueRO,
CharacterDataAccess.VelocityProjectionHits,
CharacterDataAccess.CharacterHitsBuffer,
ref characterPosition);
HandleVelocityControl(ref context, ref baseContext);
KinematicCharacterUtilities.Update_PreventGroundingFromFutureSlopeChange(
in this, ref context, ref baseContext,
CharacterDataAccess.CharacterEntity,
ref characterBody,
CharacterDataAccess.CharacterProperties.ValueRO,
CharacterDataAccess.PhysicsCollider.ValueRO,
in characterComponent.StepAndSlopeHandling);
KinematicCharacterUtilities.Update_GroundPushing(
in this, ref context, ref baseContext,
ref characterBody,
CharacterDataAccess.CharacterProperties.ValueRO,
CharacterDataAccess.LocalTransform.ValueRO,
CharacterDataAccess.DeferredImpulsesBuffer,
float3.zero);
KinematicCharacterUtilities.Update_MovementAndDecollisions(
in this, ref context, ref baseContext,
CharacterDataAccess.CharacterEntity,
ref characterBody,
CharacterDataAccess.CharacterProperties.ValueRO,
CharacterDataAccess.PhysicsCollider.ValueRO,
CharacterDataAccess.LocalTransform.ValueRO,
CharacterDataAccess.VelocityProjectionHits,
CharacterDataAccess.CharacterHitsBuffer,
CharacterDataAccess.DeferredImpulsesBuffer,
ref characterPosition);
KinematicCharacterUtilities.Update_MovingPlatformDetection(ref baseContext, ref characterBody);
KinematicCharacterUtilities.Update_ParentMomentum(ref baseContext, ref characterBody,
CharacterDataAccess.LocalTransform.ValueRO.Position);
KinematicCharacterUtilities.Update_ProcessStatefulCharacterHits(
CharacterDataAccess.CharacterHitsBuffer,
CharacterDataAccess.StatefulHitsBuffer);
}
void HandleVelocityControl(ref CharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext)
{
float deltaTime = baseContext.Time.DeltaTime;
ref KinematicCharacterBody characterBody = ref CharacterDataAccess.CharacterBody.ValueRW;
ref CharacterComponent characterComponent = ref CharacterComponent.ValueRW;
CharacterControl characterControl = CharacterControl.ValueRO;
// Planar twin-stick: smoothly approach the desired world velocity (already speed-scaled, Y==0).
float3 targetVelocity = characterControl.MoveVelocity;
CharacterControlUtilities.StandardGroundMove_Interpolated(
ref characterBody.RelativeVelocity,
targetVelocity,
characterComponent.GroundedMovementSharpness,
deltaTime,
math.up(),
math.up());
}
public void VariableUpdate(ref CharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext) { }
public void UpdateGroundingUp(ref CharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext)
{
ref KinematicCharacterBody characterBody = ref CharacterDataAccess.CharacterBody.ValueRW;
KinematicCharacterUtilities.Default_UpdateGroundingUp(
ref characterBody,
CharacterDataAccess.LocalTransform.ValueRO.Rotation);
}
public bool CanCollideWithHit(ref CharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in BasicHit hit)
{
return PhysicsUtilities.IsCollidable(hit.Material);
}
public bool IsGroundedOnHit(ref CharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in BasicHit hit, int groundingEvaluationType)
{
CharacterComponent characterComponent = CharacterComponent.ValueRO;
return KinematicCharacterUtilities.Default_IsGroundedOnHit(
in this, ref context, ref baseContext,
CharacterDataAccess.CharacterEntity,
CharacterDataAccess.PhysicsCollider.ValueRO,
CharacterDataAccess.CharacterBody.ValueRO,
CharacterDataAccess.CharacterProperties.ValueRO,
in hit,
in characterComponent.StepAndSlopeHandling,
groundingEvaluationType);
}
public void OnMovementHit(
ref CharacterUpdateContext context,
ref KinematicCharacterUpdateContext baseContext,
ref KinematicCharacterHit hit,
ref float3 remainingMovementDirection,
ref float remainingMovementLength,
float3 originalVelocityDirection,
float hitDistance)
{
ref KinematicCharacterBody characterBody = ref CharacterDataAccess.CharacterBody.ValueRW;
ref float3 characterPosition = ref CharacterDataAccess.LocalTransform.ValueRW.Position;
CharacterComponent characterComponent = CharacterComponent.ValueRO;
KinematicCharacterUtilities.Default_OnMovementHit(
in this, ref context, ref baseContext,
ref characterBody,
CharacterDataAccess.CharacterEntity,
CharacterDataAccess.CharacterProperties.ValueRO,
CharacterDataAccess.PhysicsCollider.ValueRO,
CharacterDataAccess.LocalTransform.ValueRO,
ref characterPosition,
CharacterDataAccess.VelocityProjectionHits,
ref hit,
ref remainingMovementDirection,
ref remainingMovementLength,
originalVelocityDirection,
hitDistance,
characterComponent.StepAndSlopeHandling.StepHandling,
characterComponent.StepAndSlopeHandling.MaxStepHeight,
characterComponent.StepAndSlopeHandling.CharacterWidthForStepGroundingCheck);
}
public void OverrideDynamicHitMasses(
ref CharacterUpdateContext context,
ref KinematicCharacterUpdateContext baseContext,
ref PhysicsMass characterMass,
ref PhysicsMass otherMass,
BasicHit hit)
{
}
public void ProjectVelocityOnHits(
ref CharacterUpdateContext context,
ref KinematicCharacterUpdateContext baseContext,
ref float3 velocity,
ref bool characterIsGrounded,
ref BasicHit characterGroundHit,
in DynamicBuffer<KinematicVelocityProjectionHit> velocityProjectionHits,
float3 originalVelocityDirection)
{
CharacterComponent characterComponent = CharacterComponent.ValueRO;
KinematicCharacterUtilities.Default_ProjectVelocityOnHits(
ref velocity,
ref characterIsGrounded,
ref characterGroundHit,
in velocityProjectionHits,
originalVelocityDirection,
characterComponent.StepAndSlopeHandling.ConstrainVelocityToGroundPlane,
in CharacterDataAccess.CharacterBody.ValueRO);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d2bcdc39be82b4c4dbc85b799da194f3
@@ -0,0 +1,34 @@
using Unity.Burst;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Bridges replicated <see cref="PlayerInput"/> + data-driven <see cref="EffectiveCharacterStats"/> into
/// the CC processor's per-tick <see cref="CharacterControl"/>. Replaces the M5 <c>PlayerMoveSystem</c>:
/// instead of writing PhysicsVelocity, it writes the desired world velocity that
/// <see cref="CharacterProcessor"/> lerps toward inside the fixed-step character update. Runs in
/// <see cref="PredictedSimulationSystemGroup"/> after <see cref="StatRecomputeSystem"/> (fresh MoveSpeed)
/// and before the predicted fixed-step group (where the character physics steps). Derived purely from
/// replicated input + recomputed stats → identical on server, owning client, and across rollback.
/// Filtered to <see cref="Simulate"/> for predicted ghosts only.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(StatRecomputeSystem))]
[BurstCompile]
public partial struct PlayerControlSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
foreach (var (control, input, stats) in
SystemAPI.Query<RefRW<CharacterControl>, RefRO<PlayerInput>, RefRO<EffectiveCharacterStats>>()
.WithAll<Simulate>())
{
control.ValueRW.MoveVelocity =
CharacterControlMath.DesiredMovement(input.ValueRO.Move, stats.ValueRO.MoveSpeed);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d70cdf70e3ae53440aeb7274bdb27793
@@ -1,41 +0,0 @@
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>
/// Canonical predicted system: advances each player's planar (XZ) position from twin-stick Move
/// input. Runs inside the prediction loop on the owning client (re-simulated on rollback) and
/// once per tick on the server; filtered to <see cref="Simulate"/> so only predicted ghosts move.
/// Deterministic by construction: uses <c>SystemAPI.Time.DeltaTime</c> (the fixed tick step)
/// only — no wall-clock, no <c>System.Random</c>. Move is clamped to unit length so diagonal
/// keyboard movement is not faster than cardinal. Move speed is the data-driven
/// <see cref="EffectiveCharacterStats.MoveSpeed"/> (authored base + active modifiers), recomputed
/// each tick by <see cref="StatRecomputeSystem"/> which runs before this system.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[BurstCompile]
public partial struct PlayerMoveSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float dt = SystemAPI.Time.DeltaTime;
foreach (var (transform, input, stats) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<PlayerInput>, RefRO<EffectiveCharacterStats>>()
.WithAll<Simulate>())
{
float2 move = input.ValueRO.Move;
if (math.lengthsq(move) > 1f)
move = math.normalize(move);
float3 delta = new float3(move.x, 0f, move.y) * stats.ValueRO.MoveSpeed * dt;
transform.ValueRW.Position += delta;
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 35cd3eacccc2f4172b557b2807a6df22
@@ -8,7 +8,8 @@
"Unity.Mathematics",
"Unity.Burst",
"Unity.Physics",
"Unity.NetCode"
"Unity.NetCode",
"Unity.CharacterController"
],
"includePlatforms": [],
"excludePlatforms": [],
+393
View File
@@ -169,6 +169,118 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &691660672
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 691660676}
- component: {fileID: 691660675}
- component: {fileID: 691660674}
- component: {fileID: 691660673}
m_Layer: 0
m_Name: Wall_North
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!23 &691660673
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 691660672}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: f8df9cd33fb974460a903e35a6fce3c9, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 1
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!65 &691660674
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 691660672}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!33 &691660675
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 691660672}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!4 &691660676
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 691660672}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 1.25, z: 6}
m_LocalScale: {x: 8, y: 2.5, z: 0.5}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1379903944
GameObject:
m_ObjectHideFlags: 0
@@ -214,6 +326,57 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1435545286
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1435545288}
- component: {fileID: 1435545287}
m_Layer: 0
m_Name: PhysicsConfig
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1435545287
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1435545286}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5bd6bf266b32e49ac8c5951317b6b250, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.NetCode.Physics.Hybrid::Unity.NetCode.NetCodePhysicsConfig
PhysicGroupRunMode: 1
EnableLagCompensation: 0
ServerHistorySize: 0
ClientHistorySize: 1
ClientNonGhostWorldIndex: 0
DeepCopyDynamicColliders: 1
DeepCopyStaticColliders: 0
--- !u!4 &1435545288
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1435545286}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1498433570
GameObject:
m_ObjectHideFlags: 0
@@ -245,6 +408,8 @@ MonoBehaviour:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.PlayerSpawnerAuthoring
PlayerPrefab: {fileID: 2218851646297572645, guid: a27bbed2662454377bd25279ee4a14d2, type: 3}
SpawnPoint: {x: 0, y: 1, z: 0}
SpawnRingRadius: 2.5
RingSlots: 4
--- !u!4 &1498433572
Transform:
m_ObjectHideFlags: 0
@@ -308,6 +473,230 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1694504171
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1694504175}
- component: {fileID: 1694504174}
- component: {fileID: 1694504173}
- component: {fileID: 1694504172}
m_Layer: 0
m_Name: Pillar_Center
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!23 &1694504172
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1694504171}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: f8df9cd33fb974460a903e35a6fce3c9, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 1
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!65 &1694504173
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1694504171}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!33 &1694504174
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1694504171}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!4 &1694504175
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1694504171}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 1.25, z: 0}
m_LocalScale: {x: 1.5, y: 2.5, z: 1.5}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1936735558
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1936735562}
- component: {fileID: 1936735561}
- component: {fileID: 1936735560}
- component: {fileID: 1936735559}
m_Layer: 0
m_Name: Wall_East
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!23 &1936735559
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1936735558}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: f8df9cd33fb974460a903e35a6fce3c9, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 1
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!65 &1936735560
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1936735558}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!33 &1936735561
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1936735558}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!4 &1936735562
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1936735558}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 6, y: 1.25, z: 0}
m_LocalScale: {x: 0.5, y: 2.5, z: 8}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &2143686865
GameObject:
m_ObjectHideFlags: 0
@@ -365,3 +754,7 @@ SceneRoots:
- {fileID: 2143686867}
- {fileID: 409538539}
- {fileID: 1527461113}
- {fileID: 1435545288}
- {fileID: 1694504175}
- {fileID: 1936735562}
- {fileID: 691660676}
@@ -0,0 +1,58 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Mathematics;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-helper unit tests for <see cref="CharacterControlMath.DesiredMovement"/> (M5b: Unity Character
/// Controller). Replaces the M5 PlayerMoveSystemTests: the character now moves via collide-and-slide
/// inside the predicted physics loop, so the unit-testable seam is the input→world-velocity mapping that
/// <see cref="PlayerControlSystem"/> feeds the CC processor. The actual sweep/collision/replication is
/// covered by the Play Mode runtime check (a full PhysicsWorld is not built in a bare EditMode world).
/// Netcode-free and version-independent.
/// </summary>
public class CharacterControlMathTests
{
[Test]
public void DesiredMovement_Maps_Cardinal_To_Planar_Velocity()
{
const float speed = 5f;
var v = CharacterControlMath.DesiredMovement(new float2(1f, 0f), speed);
Assert.AreEqual(speed, v.x, 1e-4f, "Move=(1,0) -> +X scaled by speed.");
Assert.AreEqual(0f, v.y, 1e-4f, "Movement is planar; Y must be zero.");
Assert.AreEqual(0f, v.z, 1e-4f, "Move=(1,0) maps to +X only; Z must be zero.");
}
[Test]
public void DesiredMovement_Clamps_Diagonal_To_Unit_Speed()
{
const float speed = 6f;
var v = CharacterControlMath.DesiredMovement(new float2(1f, 1f), speed);
float planarSpeed = math.length(new float2(v.x, v.z));
Assert.AreEqual(speed, planarSpeed, 1e-3f, "Diagonal input clamped to unit length -> speed == MoveSpeed.");
Assert.AreEqual(0f, v.y, 1e-4f, "Linear Y must stay zero.");
}
[Test]
public void DesiredMovement_Sub_Unit_Input_Is_Proportional()
{
const float speed = 10f;
var v = CharacterControlMath.DesiredMovement(new float2(0.5f, 0f), speed);
Assert.AreEqual(0.5f * speed, v.x, 1e-3f, "Sub-unit analog input scales speed proportionally (not renormalised).");
}
[Test]
public void DesiredMovement_Is_Deterministic_And_Maps_Z()
{
var a = CharacterControlMath.DesiredMovement(new float2(0f, 1f), 3f);
var b = CharacterControlMath.DesiredMovement(new float2(0f, 1f), 3f);
Assert.AreEqual(a.z, b.z, 0f, "Deterministic: identical args -> identical result.");
Assert.AreEqual(3f, a.z, 1e-4f, "Move=(0,1) maps to +Z scaled by speed.");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 051c0bdf57f9cca41963e3c2ed60b2bf
@@ -1,90 +0,0 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities determinism test for <see cref="PlayerMoveSystem"/> (the M1 predicted move
/// system). Boots a bare ECS world, registers the system in the SimulationSystemGroup, creates a
/// synthetic player (PlayerInput + EffectiveCharacterStats + LocalTransform + enabled Simulate),
/// injects a fixed delta-time, ticks N times, and asserts the position advanced by exactly
/// MoveSpeed * dt * N. As of M3 move speed is read from EffectiveCharacterStats (the data-driven
/// effective stat) rather than the removed PlayerMoveStats. Version-independent and netcode-free.
/// </summary>
public class PlayerMoveSystemTests
{
[Test]
public void PlayerMove_Advances_By_MoveSpeed_Times_Dt_Each_Tick()
{
using var world = new World("PlayerMoveTestWorld");
var simulationGroup = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
var moveSystem = world.GetOrCreateSystem<PlayerMoveSystem>();
simulationGroup.AddSystemToUpdateList(moveSystem);
simulationGroup.SortSystems();
var em = world.EntityManager;
var entity = em.CreateEntity(
typeof(PlayerInput), typeof(EffectiveCharacterStats), typeof(LocalTransform), typeof(Simulate));
const float moveSpeed = 5f;
const float dt = 0.1f;
const int ticks = 10;
em.SetComponentData(entity, LocalTransform.FromPosition(float3.zero));
em.SetComponentData(entity, new EffectiveCharacterStats { MoveSpeed = moveSpeed, TurnRateRadiansPerSec = 0f, MaxHealth = 0f });
em.SetComponentData(entity, new PlayerInput { Move = new float2(1f, 0f), Aim = float2.zero });
for (int i = 0; i < ticks; i++)
{
// Fixed delta so the predicted move is fully deterministic (no wall-clock).
world.SetTime(new TimeData(elapsedTime: dt * (i + 1), deltaTime: dt));
simulationGroup.Update();
}
var position = em.GetComponentData<LocalTransform>(entity).Position;
Assert.AreEqual(moveSpeed * dt * ticks, position.x, 1e-3f,
"X should advance by MoveSpeed * dt each tick for Move=(1,0).");
Assert.AreEqual(0f, position.y, 1e-3f, "Movement is planar; Y should stay 0.");
Assert.AreEqual(0f, position.z, 1e-3f, "Move=(1,0) maps to +X only; Z should stay 0.");
}
[Test]
public void PlayerMove_Is_Idempotent_Across_Equal_Tick_Batches()
{
// Determinism/idempotence: the same inputs and dt must yield the same result regardless
// of how the ticks are grouped (mirrors the prediction loop re-simulating a tick).
float3 RunTicks(int ticks)
{
using var world = new World("PlayerMoveDetWorld");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<PlayerMoveSystem>());
group.SortSystems();
var em = world.EntityManager;
var e = em.CreateEntity(
typeof(PlayerInput), typeof(EffectiveCharacterStats), typeof(LocalTransform), typeof(Simulate));
em.SetComponentData(e, LocalTransform.FromPosition(float3.zero));
em.SetComponentData(e, new EffectiveCharacterStats { MoveSpeed = 3f, TurnRateRadiansPerSec = 0f, MaxHealth = 0f });
em.SetComponentData(e, new PlayerInput { Move = new float2(0f, 1f), Aim = float2.zero });
for (int i = 0; i < ticks; i++)
{
world.SetTime(new TimeData(0.05f * (i + 1), 0.05f));
group.Update();
}
return em.GetComponentData<LocalTransform>(e).Position;
}
var a = RunTicks(20);
var b = RunTicks(20);
Assert.AreEqual(a.z, b.z, 1e-4f, "Two identical runs must produce identical positions.");
Assert.AreEqual(3f * 0.05f * 20f, a.z, 1e-3f, "Move=(0,1) maps to +Z by MoveSpeed*dt*N.");
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: aed328b85e7264aa9aedca0a628c6169
@@ -8,6 +8,7 @@
"Unity.Transforms",
"Unity.Collections",
"Unity.Mathematics",
"Unity.Physics",
"Unity.NetCode",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"