Initial Combat Implementation
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a7ea79ccc9c74c62a94c777999859c6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,30 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicted per-player ability cooldown gate. Holds the earliest server tick at which the
|
||||
/// owning player may fire again, so <see cref="AbilityFireSystem"/> can throttle shots
|
||||
/// deterministically across client prediction and server simulation.
|
||||
/// <para>
|
||||
/// Replicated as a <see cref="GhostField"/> so the cooldown survives the frame→tick→rollback
|
||||
/// boundary: when the client re-predicts ticks after a snapshot, it sees the same authoritative
|
||||
/// gate the server applied and converges without double-firing. Stored as a raw <c>uint</c>
|
||||
/// rather than a <see cref="NetworkTick"/> for simple, quantization-free serialization; compare
|
||||
/// by wrapping it back into a <see cref="NetworkTick"/> and using
|
||||
/// <see cref="NetworkTick.IsNewerThan"/> (raw subtraction is unsafe across tick wraparound).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public struct AbilityCooldown : IComponentData
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw tick value of the earliest tick the player may fire again. <c>0</c> = ready (no
|
||||
/// cooldown pending). Set by <see cref="AbilityFireSystem"/> to
|
||||
/// <c>serverTick + max(1, CooldownTicks)</c> on fire; treat as "still cooling down" only
|
||||
/// while a valid <see cref="NetworkTick"/> built from it is newer than the current
|
||||
/// <c>ServerTick</c>.
|
||||
/// </summary>
|
||||
[GhostField] public uint NextFireTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7a2b67b22b2a4abaa8efd84759445c0
|
||||
@@ -0,0 +1,14 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton handle to the baked ability/character definition database (config, not replicated -
|
||||
/// baked identically into both worlds from the gameplay subscene). The companion AbilityPrefabElement
|
||||
/// buffer on the same entity carries the per-ability projectile prefab entity refs.
|
||||
/// </summary>
|
||||
public struct AbilityDatabase : IComponentData
|
||||
{
|
||||
public BlobAssetReference<AbilityDatabaseBlob> Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6b297f7d451084342af25012ccb3a3e8
|
||||
@@ -0,0 +1,74 @@
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>One authored ability definition, baked immutable into the AbilityDatabase blob.</summary>
|
||||
public struct AbilityDefBlob
|
||||
{
|
||||
public byte Id; // AbilityId
|
||||
public float Damage;
|
||||
public float ProjectileSpeed;
|
||||
public float Range;
|
||||
public float AutoTargetRange;
|
||||
public float AutoTargetConeRadians;
|
||||
public int CooldownTicks;
|
||||
public FixedString64Bytes Name;
|
||||
}
|
||||
|
||||
/// <summary>One authored character-stats definition, baked immutable into the AbilityDatabase blob.</summary>
|
||||
public struct CharacterStatsBlob
|
||||
{
|
||||
public byte Id; // CharacterId
|
||||
public float MoveSpeed;
|
||||
public float TurnRateRadiansPerSec;
|
||||
public float MaxHealth;
|
||||
public FixedString64Bytes Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable designer-authored definition database, baked from ScriptableObjects to a blob asset and
|
||||
/// shared by every entity (Burst-fast, zero per-instance cost). Looked up by stable id. Entity/prefab
|
||||
/// references are NOT stored here (blobs don't remap entity refs) - see AbilityPrefabElement.
|
||||
///
|
||||
/// NOTE: the lookups are intentionally NOT 'readonly' methods. A readonly struct method forces a
|
||||
/// defensive copy of a field when calling a non-readonly member on it; copying a BlobArray breaks its
|
||||
/// relative-offset pointer, so the array would read as empty. Plain (non-readonly) methods access the
|
||||
/// BlobArray in place. Always reach these through 'ref blob.Value'.
|
||||
/// </summary>
|
||||
public struct AbilityDatabaseBlob
|
||||
{
|
||||
public BlobArray<AbilityDefBlob> Abilities;
|
||||
public BlobArray<CharacterStatsBlob> Characters;
|
||||
|
||||
/// <summary>Linear lookup by ability id (the array is tiny). Returns false if not present.</summary>
|
||||
public bool TryGetAbility(byte id, out AbilityDefBlob def)
|
||||
{
|
||||
for (int i = 0; i < Abilities.Length; i++)
|
||||
{
|
||||
if (Abilities[i].Id == id)
|
||||
{
|
||||
def = Abilities[i];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
def = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Linear lookup by character id (the array is tiny). Returns false if not present.</summary>
|
||||
public bool TryGetCharacter(byte id, out CharacterStatsBlob def)
|
||||
{
|
||||
for (int i = 0; i < Characters.Length; i++)
|
||||
{
|
||||
if (Characters[i].Id == id)
|
||||
{
|
||||
def = Characters[i];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
def = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 974be1ee95bef486ea49a4d42ecc9796
|
||||
@@ -0,0 +1,173 @@
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicted "fire" ability: on the single fully-predicting pass of each tick, spawns a
|
||||
/// Projectile ghost for every player whose PlayerInput.Fire event is set this tick and whose
|
||||
/// AbilityCooldown has elapsed. Runs in both worlds: the owning client predict-spawns the
|
||||
/// projectile (classified into the authoritative ghost by ProjectileClassificationSystem via the
|
||||
/// Projectile.SpawnId key), and the server spawns the replicated truth.
|
||||
///
|
||||
/// M3 data-driven: ability stats are read from the per-entity EffectiveAbilityStats (authored base
|
||||
/// from the AbilityDatabase blob keyed by AbilityRef, folded with the replicated StatModifier buffer
|
||||
/// by StatRecomputeSystem earlier this tick). The projectile ghost prefab is resolved per ability via
|
||||
/// the AbilityPrefabElement buffer on the AbilityDatabase singleton. Effective Speed/Damage/Range are
|
||||
/// snapshotted into the spawned Projectile, so the downstream move/damage systems are unchanged and
|
||||
/// predicted + server projectiles match (both folded the same replicated modifiers).
|
||||
///
|
||||
/// Determinism / idempotency: the prediction loop re-runs this system on rollback, so all
|
||||
/// non-idempotent effects (spawning, cooldown advance) are gated behind
|
||||
/// NetworkTime.IsFirstTimeFullyPredictingTick so they happen exactly once per tick. The absolute
|
||||
/// fire count comes from the replicated input command buffer at NetworkTime.ServerTick (not a
|
||||
/// local counter) so the SpawnId matches on client and server. No wall-clock, no System.Random,
|
||||
/// no UnityEngine.Time.
|
||||
///
|
||||
/// Auto-target is intentionally server-only: the client fires along raw aim, and the server's
|
||||
/// authoritative Projectile.Direction GhostField reconciles the predicted projectile to the
|
||||
/// assisted heading.
|
||||
/// </summary>
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(PlayerAimSystem))]
|
||||
[BurstCompile]
|
||||
public partial struct AbilityFireSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<AbilityDatabase>();
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
// Spawning is a one-off effect: only run on the unique fully-predicting pass of this tick
|
||||
// so a rollback re-simulation does not double-spawn.
|
||||
var networkTime = SystemAPI.GetSingleton<NetworkTime>();
|
||||
if (!networkTime.IsFirstTimeFullyPredictingTick)
|
||||
return;
|
||||
|
||||
var serverTick = networkTime.ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
|
||||
// Per-ability projectile ghost prefabs live on the AbilityDatabase singleton's companion buffer.
|
||||
var dbEntity = SystemAPI.GetSingletonEntity<AbilityDatabase>();
|
||||
var abilityPrefabs = SystemAPI.GetBuffer<AbilityPrefabElement>(dbEntity);
|
||||
|
||||
bool isServer = state.WorldUnmanaged.IsServer();
|
||||
|
||||
// Server-only auto-target candidate set: training-dummy world XZ positions, collected once.
|
||||
var candidatePositions = new NativeList<float3>(Allocator.Temp);
|
||||
if (isServer)
|
||||
{
|
||||
foreach (var dummyTransform in
|
||||
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<TrainingDummyTag>())
|
||||
{
|
||||
candidatePositions.Add(dummyTransform.ValueRO.Position);
|
||||
}
|
||||
}
|
||||
var candidates = candidatePositions.AsArray();
|
||||
|
||||
var ecb = new EntityCommandBuffer(state.WorldUpdateAllocator);
|
||||
|
||||
foreach (var (input, facing, xform, eff, abilityRef, cd, owner, entity) in
|
||||
SystemAPI.Query<RefRO<PlayerInput>, RefRO<PlayerFacing>, RefRO<LocalTransform>,
|
||||
RefRO<EffectiveAbilityStats>, RefRO<AbilityRef>, RefRW<AbilityCooldown>,
|
||||
RefRO<GhostOwner>>()
|
||||
.WithAll<Simulate>()
|
||||
.WithEntityAccess())
|
||||
{
|
||||
// The InputEvent on the component carries the per-tick delta: set => fired this tick.
|
||||
if (!input.ValueRO.Fire.IsSet)
|
||||
continue;
|
||||
|
||||
// Cooldown gate. NextFireTick == 0 means "ready". Otherwise the player may fire only
|
||||
// once serverTick is at-or-newer than the stored tick (i.e. the stored tick is not
|
||||
// strictly newer than now).
|
||||
uint nextFireRaw = cd.ValueRO.NextFireTick;
|
||||
if (nextFireRaw != 0)
|
||||
{
|
||||
var nextTick = new NetworkTick(nextFireRaw);
|
||||
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick))
|
||||
continue; // still cooling down
|
||||
}
|
||||
|
||||
// Resolve the projectile ghost prefab for this player's selected ability id.
|
||||
Entity prefab = Entity.Null;
|
||||
for (int i = 0; i < abilityPrefabs.Length; i++)
|
||||
{
|
||||
if (abilityPrefabs[i].Id == abilityRef.ValueRO.Id)
|
||||
{
|
||||
prefab = abilityPrefabs[i].Prefab;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (prefab == Entity.Null)
|
||||
continue; // ability has no projectile prefab wired
|
||||
|
||||
// Absolute (monotonic) fire count from the replicated command buffer at this tick.
|
||||
// This is the classification key shared by client prediction and server truth.
|
||||
var inputBuffer = SystemAPI.GetBuffer<InputBufferData<PlayerInput>>(entity);
|
||||
if (!inputBuffer.GetDataAtTick(serverTick, out var applied))
|
||||
continue;
|
||||
uint absoluteFireCount = applied.InternalInput.Fire.Count;
|
||||
|
||||
float2 rawAim = facing.ValueRO.Direction;
|
||||
if (math.lengthsq(rawAim) < 1e-6f)
|
||||
rawAim = new float2(0f, 1f);
|
||||
else
|
||||
rawAim = math.normalize(rawAim);
|
||||
|
||||
// Client fires along raw aim; only the server applies the auto-target assist cone.
|
||||
float2 dir = rawAim;
|
||||
if (isServer && eff.ValueRO.AutoTargetRange > 0f)
|
||||
{
|
||||
dir = AutoTarget.Resolve(
|
||||
xform.ValueRO.Position,
|
||||
rawAim,
|
||||
eff.ValueRO.AutoTargetRange,
|
||||
eff.ValueRO.AutoTargetConeRadians,
|
||||
candidates);
|
||||
}
|
||||
|
||||
uint spawnId = (uint)owner.ValueRO.NetworkId << 16 | absoluteFireCount;
|
||||
|
||||
var projectile = ecb.Instantiate(prefab);
|
||||
|
||||
float3 planarDir = new float3(dir.x, 0f, dir.y);
|
||||
float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f;
|
||||
spawnPos.y = xform.ValueRO.Position.y;
|
||||
quaternion rot = quaternion.LookRotationSafe(planarDir, math.up());
|
||||
|
||||
ecb.SetComponent(projectile, LocalTransform.FromPositionRotation(spawnPos, rot));
|
||||
ecb.SetComponent(projectile, new GhostOwner { NetworkId = owner.ValueRO.NetworkId });
|
||||
// Snapshot the effective ability stats into the projectile (base + modifiers, computed
|
||||
// identically on both worlds), so the move/damage systems need no modifier lookup.
|
||||
ecb.SetComponent(projectile, new Projectile
|
||||
{
|
||||
Direction = math.normalize(dir),
|
||||
SpawnId = spawnId,
|
||||
Speed = eff.ValueRO.ProjectileSpeed,
|
||||
Damage = eff.ValueRO.Damage,
|
||||
Range = eff.ValueRO.Range,
|
||||
DistanceTravelled = 0f,
|
||||
});
|
||||
|
||||
// Earliest raw tick the player may fire again. Clamp cooldown to >= 1 tick.
|
||||
uint cooldownTicks = (uint)math.max(1, eff.ValueRO.CooldownTicks);
|
||||
cd.ValueRW.NextFireTick = serverTick.TickIndexForValidTick + cooldownTicks;
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
candidatePositions.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 473b7521bce4d4d1abc794bcd4e8e6fe
|
||||
@@ -0,0 +1,15 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Companion buffer on the AbilityDatabase singleton mapping an ability id to its projectile ghost
|
||||
/// prefab entity. Prefab/entity references are kept OUT of the blob (blob assets don't remap entity
|
||||
/// references); they are baked here via GetEntity, which the entity serializer patches correctly.
|
||||
/// </summary>
|
||||
public struct AbilityPrefabElement : IBufferElementData
|
||||
{
|
||||
public byte Id; // AbilityId
|
||||
public Entity Prefab;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 91844bb3b4d7843318fc0cdbfe68d43e
|
||||
@@ -0,0 +1,15 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Which authored ability definition occupies this entity's primary slot - a light replicated key
|
||||
/// into the AbilityDatabase blob, replacing M2's inlined AbilityStats values. Replicated so an
|
||||
/// ability swap is server-authoritative and prediction-correct. <c>Id</c> stores an <see cref="AbilityId"/>.
|
||||
/// </summary>
|
||||
public struct AbilityRef : IComponentData
|
||||
{
|
||||
[GhostField] public byte Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d6ea08a11ef3d4afdb722b735ca3ed03
|
||||
@@ -0,0 +1,92 @@
|
||||
using Unity.Collections;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, deterministic auto-target assist helper for player abilities (M2 combat). Given a shooter
|
||||
/// position, a raw aim direction, and a set of candidate target positions, picks the best target
|
||||
/// inside an assist cone and snaps the shot toward it; otherwise returns the raw aim unchanged.
|
||||
/// <para>
|
||||
/// Authored as a <see langword="static"/> class (no state) so it is Burst-safe and allocation-free,
|
||||
/// callable from inside predicted/jobified systems. It is intended to run server-side only (see
|
||||
/// <c>AbilityFireSystem</c>) — the server's authoritative <c>Projectile.Direction</c> GhostField then
|
||||
/// reconciles the client's raw-aim predicted projectile. Determinism: no wall-clock, no randomness,
|
||||
/// pure math; ties are broken by smallest candidate index so identical inputs always yield identical
|
||||
/// output across the prediction loop and across machines.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AutoTarget
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the planar (XZ) direction a shot should take. Returns the normalized direction toward
|
||||
/// the nearest candidate within <paramref name="autoTargetRange"/> whose bearing from
|
||||
/// <paramref name="from"/> lies within <paramref name="coneHalfAngleRadians"/> of
|
||||
/// <paramref name="rawAimDir"/>; if no candidate qualifies, returns <paramref name="rawAimDir"/>
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
/// <param name="from">Shooter world position; only the XZ plane is considered.</param>
|
||||
/// <param name="rawAimDir">
|
||||
/// Caller-normalized planar aim direction (world XZ mapped to <c>float2(x, y)</c>). If it is
|
||||
/// effectively zero-length, it is returned unchanged (no valid heading to assist).
|
||||
/// </param>
|
||||
/// <param name="autoTargetRange">Max planar distance to consider a candidate; <c>0</c> (or less) disables assist.</param>
|
||||
/// <param name="coneHalfAngleRadians">Half-angle of the assist cone, measured from <paramref name="rawAimDir"/>.</param>
|
||||
/// <param name="candidatePositions">Candidate target world positions (XZ used). Read-only; not modified.</param>
|
||||
/// <returns>
|
||||
/// The normalized direction toward the chosen candidate, or <paramref name="rawAimDir"/> when no
|
||||
/// candidate qualifies. Ties on distance are broken by the smallest candidate index for determinism.
|
||||
/// </returns>
|
||||
public static float2 Resolve(float3 from, float2 rawAimDir, float autoTargetRange, float coneHalfAngleRadians,
|
||||
in NativeArray<float3> candidatePositions)
|
||||
{
|
||||
// No valid heading to assist along — caller guarantees normalization, but guard zero-length.
|
||||
if (math.lengthsq(rawAimDir) < 1e-6f)
|
||||
return rawAimDir;
|
||||
|
||||
// Disabled / nothing to consider.
|
||||
if (autoTargetRange <= 0f || candidatePositions.Length == 0)
|
||||
return rawAimDir;
|
||||
|
||||
float rangeSq = autoTargetRange * autoTargetRange;
|
||||
float cosCone = math.cos(coneHalfAngleRadians);
|
||||
|
||||
int bestIndex = -1;
|
||||
float bestDistSq = float.MaxValue;
|
||||
float2 bestDir = rawAimDir;
|
||||
|
||||
for (int i = 0; i < candidatePositions.Length; i++)
|
||||
{
|
||||
// Planar (XZ) offset from shooter to candidate.
|
||||
float3 offset = candidatePositions[i] - from;
|
||||
float2 planar = new float2(offset.x, offset.z);
|
||||
|
||||
float distSq = math.lengthsq(planar);
|
||||
|
||||
// Skip self / coincident candidates (effectively zero distance → undefined bearing).
|
||||
if (distSq < 1e-6f)
|
||||
continue;
|
||||
|
||||
// Out of range.
|
||||
if (distSq > rangeSq)
|
||||
continue;
|
||||
|
||||
// Bearing test: dot of unit bearing with the (unit) raw aim vs cos(half-angle).
|
||||
float2 dir = planar * math.rsqrt(distSq); // normalized planar bearing
|
||||
float dot = math.dot(dir, rawAimDir);
|
||||
if (dot < cosCone)
|
||||
continue; // outside the assist cone
|
||||
|
||||
// Nearest wins; strict less-than keeps the first (smallest-index) candidate on ties.
|
||||
if (distSq < bestDistSq)
|
||||
{
|
||||
bestDistSq = distSq;
|
||||
bestIndex = i;
|
||||
bestDir = dir;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex >= 0 ? bestDir : rawAimDir;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac02264d177dd426e8f6972c7c3ceaae
|
||||
@@ -0,0 +1,20 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// One pending hit against a damageable entity, queued as a per-entity buffer element. The server
|
||||
/// appends a DamageEvent when a projectile hits (ProjectileDamageSystem), then HealthApplyDamageSystem
|
||||
/// drains the buffer once per tick to subtract from Health. Buffering decouples hit detection from
|
||||
/// health resolution and lets multiple simultaneous hits accumulate before being applied. Not
|
||||
/// replicated — only Health.Current is a GhostField; the buffer is server-side and cleared each tick.
|
||||
/// </summary>
|
||||
public struct DamageEvent : IBufferElementData
|
||||
{
|
||||
/// <summary>Damage to subtract from the target's Health.Current (world health units).</summary>
|
||||
public float Amount;
|
||||
|
||||
/// <summary>NetworkId of the firing player that caused this hit (attribution / self-hit filtering upstream).</summary>
|
||||
public int SourceNetworkId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 498738282d585418893b23454a4b88a0
|
||||
@@ -0,0 +1,20 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-entity effective ability stats: the authored base (from the AbilityDatabase blob keyed by
|
||||
/// AbilityRef) folded with the entity's StatModifier buffer by StatRecomputeSystem each predicted
|
||||
/// tick. Derived/local, NOT replicated - both worlds recompute it deterministically from the
|
||||
/// replicated modifier buffer, so it matches under prediction without being in the snapshot.
|
||||
/// </summary>
|
||||
public struct EffectiveAbilityStats : IComponentData
|
||||
{
|
||||
public float Damage;
|
||||
public float ProjectileSpeed;
|
||||
public float Range;
|
||||
public float AutoTargetRange;
|
||||
public float AutoTargetConeRadians;
|
||||
public int CooldownTicks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a8bb3a5c343e74e7fb249e96c0c55fdc
|
||||
@@ -0,0 +1,16 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-entity effective character stats (movement + survivability): authored base (from the
|
||||
/// CharacterStats blob keyed by CharacterStatsRef) folded with the StatModifier buffer by
|
||||
/// StatRecomputeSystem each predicted tick. Derived/local, NOT replicated (see EffectiveAbilityStats).
|
||||
/// </summary>
|
||||
public struct EffectiveCharacterStats : IComponentData
|
||||
{
|
||||
public float MoveSpeed;
|
||||
public float TurnRateRadiansPerSec;
|
||||
public float MaxHealth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d8413eebbeda4761b33430128e7a437
|
||||
@@ -0,0 +1,20 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Hit points for any damageable ghost (players, training dummies). Server-authoritative:
|
||||
/// only server systems write <see cref="Current"/>; clients receive it via the [GhostField]
|
||||
/// for display and prediction reconciliation. <see cref="Max"/> is baked identically on both
|
||||
/// worlds and is not replicated. Added by PlayerBaker / TrainingDummyBaker.
|
||||
/// </summary>
|
||||
public struct Health : IComponentData
|
||||
{
|
||||
/// <summary>Current hit points. Replicated for display and reconciles the predicted value against the server's authoritative state.</summary>
|
||||
[GhostField] public float Current;
|
||||
|
||||
/// <summary>Maximum hit points. Baked identically on client and server; not replicated.</summary>
|
||||
public float Max;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dbe438fe95346418f9ae1908c9288a75
|
||||
@@ -0,0 +1,15 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Hit-test radius (world units) for a damageable entity. Baked identically on client and server
|
||||
/// from authoring so projectile collision is deterministic; consumed server-side by
|
||||
/// ProjectileDamageSystem to decide whether a projectile struck this entity. Not replicated.
|
||||
/// </summary>
|
||||
public struct HitRadius : IComponentData
|
||||
{
|
||||
/// <summary>Collision radius in world units; the projectile hit test compares planar (XZ) distance against this.</summary>
|
||||
public float Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df72a4dfa71044e5683932cb89ec912a
|
||||
@@ -0,0 +1,39 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Ghost component on the projectile prefab. Drives predicted+server movement and carries the
|
||||
/// classification key used to reconcile a client's predicted-spawned projectile with the server's
|
||||
/// authoritative ghost. <see cref="Direction"/> is replicated so the server's auto-targeted aim
|
||||
/// reconciles the client's raw-aim prediction; <see cref="SpawnId"/> is replicated so it lives in
|
||||
/// snapshot history for the predicted-spawn classifier. Speed/Damage/Range are baked on the prefab
|
||||
/// (identical both worlds → deterministic) and not replicated; DistanceTravelled is integrated
|
||||
/// locally each tick and not replicated.
|
||||
/// </summary>
|
||||
public struct Projectile : IComponentData
|
||||
{
|
||||
/// <summary>Planar XZ travel direction (world XZ mapped to float2 x,y), normalized.</summary>
|
||||
[GhostField(Quantization = 1000)] public float2 Direction;
|
||||
|
||||
/// <summary>
|
||||
/// Classification key: (ownerNetId << 16) | absoluteFireCount. Replicated so it is present
|
||||
/// in snapshot history; the client classifier matches this against its predicted spawn.
|
||||
/// </summary>
|
||||
[GhostField] public uint SpawnId;
|
||||
|
||||
/// <summary>Travel speed in units/second. Baked on the prefab; not replicated.</summary>
|
||||
public float Speed;
|
||||
|
||||
/// <summary>Damage applied on hit. Baked on the prefab; not replicated.</summary>
|
||||
public float Damage;
|
||||
|
||||
/// <summary>Max travel distance before the server expires the projectile. Baked on the prefab; not replicated.</summary>
|
||||
public float Range;
|
||||
|
||||
/// <summary>Integrated distance travelled (predicted on client + authoritative on server). Not replicated.</summary>
|
||||
public float DistanceTravelled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 33b95996c22674decbe6d858e8e37a73
|
||||
@@ -0,0 +1,42 @@
|
||||
using Unity.Burst;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicted projectile integrator: advances every live <see cref="Projectile"/> along its
|
||||
/// replicated planar (XZ) <see cref="Projectile.Direction"/> at its baked <see cref="Projectile.Speed"/>,
|
||||
/// accumulating <see cref="Projectile.DistanceTravelled"/>. Runs inside the prediction loop on the
|
||||
/// owning client (re-simulated on rollback) and once per tick on the server, after
|
||||
/// <see cref="AbilityFireSystem"/> has spawned this tick's shots; 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>. Pure motion: range expiry and
|
||||
/// destruction are server-authoritative in <c>ProjectileDamageSystem</c>, so this system never
|
||||
/// performs structural changes and is fully idempotent across rollback re-simulation.
|
||||
/// </summary>
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(AbilityFireSystem))]
|
||||
[BurstCompile]
|
||||
public partial struct ProjectileMoveSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
float dt = SystemAPI.Time.DeltaTime;
|
||||
|
||||
foreach (var (transform, projectile) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRW<Projectile>>()
|
||||
.WithAll<Simulate>())
|
||||
{
|
||||
float step = projectile.ValueRO.Speed * dt;
|
||||
float3 dir = new float3(projectile.ValueRO.Direction.x, 0f, projectile.ValueRO.Direction.y);
|
||||
|
||||
transform.ValueRW.Position += dir * step;
|
||||
projectile.ValueRW.DistanceTravelled += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7e91cb40d3af4ff2b260589a3b19b31
|
||||
@@ -0,0 +1,16 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Baked singleton holding the entity reference to the projectile ghost prefab. Present in both
|
||||
/// client and server worlds (the prefab is a predicted ghost), so AbilityFireSystem can instantiate
|
||||
/// it deterministically during prediction on either side. Authored once via ProjectileSpawnerAuthoring;
|
||||
/// AbilityFireSystem reads the prefab's baked Projectile component for Speed/Damage/Range. Not replicated.
|
||||
/// </summary>
|
||||
public struct ProjectileSpawner : IComponentData
|
||||
{
|
||||
/// <summary>The projectile ghost prefab to instantiate when a player fires.</summary>
|
||||
public Entity Prefab;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 49212356bd330423ca66ed65e44f90f4
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>Stable, index-independent key for an authored ability definition in the AbilityDatabase blob.</summary>
|
||||
public enum AbilityId : byte
|
||||
{
|
||||
None = 0,
|
||||
Primary = 1,
|
||||
FastLight = 2,
|
||||
SlowHeavy = 3,
|
||||
}
|
||||
|
||||
/// <summary>Stable key for an authored character-stats definition in the AbilityDatabase blob.</summary>
|
||||
public enum CharacterId : byte
|
||||
{
|
||||
None = 0,
|
||||
Default = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which base stat a <see cref="StatModifier"/> targets. Replicated as a raw byte on the modifier
|
||||
/// buffer to keep the generated ghost serializer trivial; mapped back to this enum only in StatMath.
|
||||
/// </summary>
|
||||
public enum StatTarget : byte
|
||||
{
|
||||
Damage = 0,
|
||||
CooldownTicks = 1,
|
||||
Range = 2,
|
||||
ProjectileSpeed = 3,
|
||||
AutoTargetRange = 4,
|
||||
AutoTargetConeRadians = 5,
|
||||
MoveSpeed = 6,
|
||||
TurnRate = 7,
|
||||
MaxHealth = 8,
|
||||
}
|
||||
|
||||
/// <summary>How a <see cref="StatModifier"/> combines into the effective stat.</summary>
|
||||
public enum ModOp : byte
|
||||
{
|
||||
Flat = 0, // additive: + Value
|
||||
PercentAdd = 1, // additive percent, pooled into (1 + sum Value)
|
||||
PercentMult = 2, // multiplicative percent, product of (1 + Value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b103cd0b266a4776bcd47c2f12b1d7d
|
||||
@@ -0,0 +1,42 @@
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, deterministic folding of a StatModifier set into an effective value for one StatTarget:
|
||||
/// effective = (base + sum flat) * (1 + sum percentAdd) * product(1 + percentMult).
|
||||
/// Order-independent within each op class, Burst-friendly, and unit-tested like AutoTarget.Resolve.
|
||||
/// Returns the raw fold; consumers clamp domain bounds (e.g. cooldown >= 1 tick).
|
||||
/// </summary>
|
||||
public static class StatMath
|
||||
{
|
||||
public static float Apply(float baseValue, StatTarget target, in DynamicBuffer<StatModifier> mods)
|
||||
{
|
||||
return Apply(baseValue, target, mods.AsNativeArray());
|
||||
}
|
||||
|
||||
public static float Apply(float baseValue, StatTarget target, in NativeArray<StatModifier> mods)
|
||||
{
|
||||
float flat = 0f;
|
||||
float percentAdd = 0f;
|
||||
float percentMult = 1f;
|
||||
byte t = (byte)target;
|
||||
|
||||
for (int i = 0; i < mods.Length; i++)
|
||||
{
|
||||
var m = mods[i];
|
||||
if (m.Target != t)
|
||||
continue;
|
||||
switch ((ModOp)m.Op)
|
||||
{
|
||||
case ModOp.Flat: flat += m.Value; break;
|
||||
case ModOp.PercentAdd: percentAdd += m.Value; break;
|
||||
case ModOp.PercentMult: percentMult *= 1f + m.Value; break;
|
||||
}
|
||||
}
|
||||
|
||||
return (baseValue + flat) * (1f + percentAdd) * percentMult;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 49228160eb5a44dfda64b623389055d5
|
||||
@@ -0,0 +1,34 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// One runtime stat modifier (from an upgrade, pickup, or buff) on a modifiable entity. The
|
||||
/// per-entity DynamicBuffer of these is the server-authoritative source StatRecomputeSystem folds
|
||||
/// into the effective-stat components, on both the server and the predicting client.
|
||||
///
|
||||
/// Replication: this is a GhostField buffer, so it is part of the ghost snapshot and is restored on
|
||||
/// rollback - that is what lets the predicting owner recompute identical effective stats.
|
||||
/// OwnerSendType.All is explicit so the owning (predicting) client receives it; without it the
|
||||
/// owner would recompute from an empty list and mispredict every tick. Target/Op replicate as raw
|
||||
/// bytes (not the enums) to keep the generated serializer trivial and avoid the cross-assembly
|
||||
/// enum-codegen hazard that already de-Bursted ProjectileClassificationSystem.
|
||||
/// </summary>
|
||||
[GhostComponent(OwnerSendType = SendToOwnerType.All)]
|
||||
[InternalBufferCapacity(8)]
|
||||
public struct StatModifier : IBufferElementData
|
||||
{
|
||||
/// <summary>The <see cref="StatTarget"/> this modifier applies to (stored as a byte).</summary>
|
||||
[GhostField] public byte Target;
|
||||
|
||||
/// <summary>The <see cref="ModOp"/> combine operation (stored as a byte).</summary>
|
||||
[GhostField] public byte Op;
|
||||
|
||||
/// <summary>Magnitude: a flat amount, or a fractional percent (0.1 = +10%).</summary>
|
||||
[GhostField(Quantization = 1000)] public float Value;
|
||||
|
||||
/// <summary>Provenance tag (e.g. pickup SpawnId / debug sentinel). Reserved for future ClearByType / timed buffs.</summary>
|
||||
[GhostField] public uint SourceId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f44086f6603c74fe6909b731cf99ff82
|
||||
@@ -0,0 +1,68 @@
|
||||
using Unity.Burst;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Folds each modifiable entity's authored base stats (from the AbilityDatabase blob, keyed by
|
||||
/// 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;
|
||||
/// 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
|
||||
/// filter would be WRONG here - the Effective* components are NOT in the ghost snapshot and would go
|
||||
/// stale across reprediction.
|
||||
/// </summary>
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(PlayerAimSystem))]
|
||||
[UpdateBefore(typeof(PlayerMoveSystem))]
|
||||
[BurstCompile]
|
||||
public partial struct StatRecomputeSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<AbilityDatabase>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var database = SystemAPI.GetSingleton<AbilityDatabase>();
|
||||
ref var db = ref database.Value.Value;
|
||||
|
||||
foreach (var (abilityRef, charRef, mods, effAbility, effChar) in
|
||||
SystemAPI.Query<RefRO<AbilityRef>, RefRO<CharacterStatsRef>, DynamicBuffer<StatModifier>,
|
||||
RefRW<EffectiveAbilityStats>, RefRW<EffectiveCharacterStats>>()
|
||||
.WithAll<Simulate>())
|
||||
{
|
||||
if (db.TryGetAbility(abilityRef.ValueRO.Id, out var a))
|
||||
{
|
||||
effAbility.ValueRW = new EffectiveAbilityStats
|
||||
{
|
||||
Damage = StatMath.Apply(a.Damage, StatTarget.Damage, mods),
|
||||
ProjectileSpeed = StatMath.Apply(a.ProjectileSpeed, StatTarget.ProjectileSpeed, mods),
|
||||
Range = StatMath.Apply(a.Range, StatTarget.Range, mods),
|
||||
AutoTargetRange = StatMath.Apply(a.AutoTargetRange, StatTarget.AutoTargetRange, mods),
|
||||
AutoTargetConeRadians = StatMath.Apply(a.AutoTargetConeRadians, StatTarget.AutoTargetConeRadians, mods),
|
||||
CooldownTicks = (int)math.round(StatMath.Apply(a.CooldownTicks, StatTarget.CooldownTicks, mods)),
|
||||
};
|
||||
}
|
||||
|
||||
if (db.TryGetCharacter(charRef.ValueRO.Id, out var c))
|
||||
{
|
||||
effChar.ValueRW = new EffectiveCharacterStats
|
||||
{
|
||||
MoveSpeed = StatMath.Apply(c.MoveSpeed, StatTarget.MoveSpeed, mods),
|
||||
TurnRateRadiansPerSec = StatMath.Apply(c.TurnRateRadiansPerSec, StatTarget.TurnRate, mods),
|
||||
MaxHealth = StatMath.Apply(c.MaxHealth, StatTarget.MaxHealth, mods),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4aa8d74c3871e4fdf9b9fc45a0193130
|
||||
@@ -0,0 +1,26 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Baked singleton describing the training-dummy field to spawn at world start. Consumed once by
|
||||
/// the server-only TrainingDummySpawnSystem, which instantiates <see cref="Count"/> dummies from
|
||||
/// <see cref="Prefab"/> and then destroys this singleton so the spawn runs exactly once. Not
|
||||
/// replicated — dummies are spawned authoritatively on the server and reach clients as ghosts.
|
||||
/// </summary>
|
||||
public struct TrainingDummySpawner : IComponentData
|
||||
{
|
||||
/// <summary>Baked entity prefab to instantiate for each dummy.</summary>
|
||||
public Entity Prefab;
|
||||
|
||||
/// <summary>Number of dummies to spawn.</summary>
|
||||
public int Count;
|
||||
|
||||
/// <summary>World-unit gap between consecutive dummies along the spawn line (X axis).</summary>
|
||||
public float Spacing;
|
||||
|
||||
/// <summary>World-space position of the first dummy; subsequent dummies offset by <see cref="Spacing"/>.</summary>
|
||||
public float3 Origin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a8f27601fb20640718aaed2c48d1d016
|
||||
@@ -0,0 +1,11 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Zero-size marker identifying a training-dummy enemy. Server auto-target collects
|
||||
/// <see cref="TrainingDummyTag"/> entities as candidates, and the damage/death path destroys
|
||||
/// a dummy when its <c>Health.Current</c> reaches zero. Added by TrainingDummyBaker.
|
||||
/// </summary>
|
||||
public struct TrainingDummyTag : IComponentData { }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ebe9114e40db748669d89ef861415bac
|
||||
@@ -0,0 +1,17 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// A world pickup that, on player overlap, grants one StatModifier (server-authoritative) and is then
|
||||
/// destroyed. Mirrors a single StatModifier payload (Target/Op as bytes). The pickup is an
|
||||
/// interpolated ghost so clients see and despawn it; the grant is applied by UpgradePickupSystem.
|
||||
/// </summary>
|
||||
public struct UpgradePickup : IComponentData
|
||||
{
|
||||
public byte Target; // StatTarget
|
||||
public byte Op; // ModOp
|
||||
public float Value;
|
||||
public uint SourceId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a0d34f4d0b12241c7b0f0b1d002142f3
|
||||
@@ -0,0 +1,17 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton describing how many upgrade pickups to spawn and where (mirrors TrainingDummySpawner).
|
||||
/// Consumed once by the server's UpgradePickupSpawnSystem.
|
||||
/// </summary>
|
||||
public struct UpgradePickupSpawner : IComponentData
|
||||
{
|
||||
public Entity Prefab;
|
||||
public float3 Origin;
|
||||
public int Count;
|
||||
public float Spacing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 059368b62be834614ae3a110a51d42da
|
||||
@@ -0,0 +1,15 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Which authored character-stats definition this entity uses - a light key into the CharacterStats
|
||||
/// blob, replacing M2's inlined PlayerMoveStats values. Not replicated (baked identically on both
|
||||
/// worlds); promote to a GhostField if runtime character changes are ever needed. <c>Id</c> stores a
|
||||
/// <see cref="CharacterId"/>.
|
||||
/// </summary>
|
||||
public struct CharacterStatsRef : IComponentData
|
||||
{
|
||||
public byte Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fd41617abd03c4a409567adcce55d13a
|
||||
@@ -7,7 +7,7 @@ namespace ProjectM.Simulation
|
||||
/// <summary>
|
||||
/// Twin-stick player input (server-authoritative, input-only clients). Gathered once per frame
|
||||
/// on the owning client in <see cref="GhostInputSystemGroup"/> and streamed to the server via
|
||||
/// AutoCommandTarget. Netcode source-gen produces InputBufferData<PlayerInput> plus the
|
||||
/// AutoCommandTarget. Netcode source-gen produces InputBufferData<PlayerInput> plus the
|
||||
/// copy/apply systems from this type. The [GhostField]s let remote owners be predicted; the
|
||||
/// data replays deterministically under rollback.
|
||||
/// </summary>
|
||||
@@ -16,14 +16,18 @@ namespace ProjectM.Simulation
|
||||
/// <summary>WASD / left-stick movement, normalized to roughly -1..1 per axis.</summary>
|
||||
[GhostField(Quantization = 1000)] public float2 Move;
|
||||
|
||||
/// <summary>Right-stick / cursor aim direction (normalized). Zero => face movement direction.</summary>
|
||||
/// <summary>Right-stick / cursor aim direction (normalized). Zero => face movement direction.</summary>
|
||||
[GhostField(Quantization = 1000)] public float2 Aim;
|
||||
|
||||
/// <summary>Primary ability fire. InputEvent survives the frame→tick→rollback boundary so a press fires exactly once.</summary>
|
||||
[GhostField] public InputEvent Fire;
|
||||
|
||||
public FixedString512Bytes ToFixedString()
|
||||
{
|
||||
var s = new FixedString512Bytes();
|
||||
s.Append(Move.x); s.Append(','); s.Append(Move.y); s.Append(';');
|
||||
s.Append(Aim.x); s.Append(','); s.Append(Aim.y);
|
||||
s.Append(Aim.x); s.Append(','); s.Append(Aim.y); s.Append(';');
|
||||
s.Append(Fire.Count);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-player movement tunables, baked from authoring. Identical on client (re-prediction) and
|
||||
/// server so movement is deterministic. Not replicated.
|
||||
/// </summary>
|
||||
public struct PlayerMoveStats : IComponentData
|
||||
{
|
||||
/// <summary>Planar movement speed in units/second.</summary>
|
||||
public float MoveSpeed;
|
||||
|
||||
/// <summary>Max turn rate (radians/second) when rotating toward the facing direction.</summary>
|
||||
public float TurnRateRadiansPerSec;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cf5fc79d6c67d4ef39ba4e7e9457dd85
|
||||
@@ -12,7 +12,9 @@ namespace ProjectM.Simulation
|
||||
/// 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.
|
||||
/// 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]
|
||||
@@ -24,7 +26,7 @@ namespace ProjectM.Simulation
|
||||
float dt = SystemAPI.Time.DeltaTime;
|
||||
|
||||
foreach (var (transform, input, stats) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<PlayerInput>, RefRO<PlayerMoveStats>>()
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<PlayerInput>, RefRO<EffectiveCharacterStats>>()
|
||||
.WithAll<Simulate>())
|
||||
{
|
||||
float2 move = input.ValueRO.Move;
|
||||
|
||||
Reference in New Issue
Block a user