Initial Combat Implementation
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7329bc1c607064c54a087b67292b8ff5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Generic;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Bakes the designer-authored ability + character definitions into a single AbilityDatabase blob
|
||||
/// singleton (immutable, shared, Burst-fast) plus a companion AbilityPrefabElement buffer holding the
|
||||
/// per-ability projectile ghost prefab entity refs (entity refs cannot live in a blob). Place ONE of
|
||||
/// these in the gameplay subscene; it streams identically into the client and server worlds (config,
|
||||
/// not replicated). DependsOn each definition so a value change in an SO re-bakes the blob.
|
||||
/// </summary>
|
||||
public class AbilityDatabaseAuthoring : MonoBehaviour
|
||||
{
|
||||
[Tooltip("All ability definitions available in the game. Indexed at runtime by AbilityId.")]
|
||||
public List<AbilityDefinition> Abilities = new List<AbilityDefinition>();
|
||||
|
||||
[Tooltip("All character-stats definitions. Indexed at runtime by CharacterId.")]
|
||||
public List<CharacterStatsDefinition> Characters = new List<CharacterStatsDefinition>();
|
||||
|
||||
private class DatabaseBaker : Baker<AbilityDatabaseAuthoring>
|
||||
{
|
||||
public override void Bake(AbilityDatabaseAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(TransformUsageFlags.None);
|
||||
|
||||
int abilityCount = authoring.Abilities != null ? authoring.Abilities.Count : 0;
|
||||
int charCount = authoring.Characters != null ? authoring.Characters.Count : 0;
|
||||
|
||||
var builder = new BlobBuilder(Allocator.Temp);
|
||||
ref var root = ref builder.ConstructRoot<AbilityDatabaseBlob>();
|
||||
|
||||
var abilityArray = builder.Allocate(ref root.Abilities, abilityCount);
|
||||
for (int i = 0; i < abilityCount; i++)
|
||||
{
|
||||
var def = authoring.Abilities[i];
|
||||
if (def == null) { abilityArray[i] = default; continue; }
|
||||
DependsOn(def);
|
||||
abilityArray[i] = new AbilityDefBlob
|
||||
{
|
||||
Id = (byte)def.Id,
|
||||
Damage = def.Damage,
|
||||
ProjectileSpeed = def.ProjectileSpeed,
|
||||
Range = def.Range,
|
||||
AutoTargetRange = def.AutoTargetRange,
|
||||
AutoTargetConeRadians = math.radians(def.AutoTargetConeDegrees),
|
||||
CooldownTicks = def.CooldownTicks,
|
||||
Name = def.DisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
var charArray = builder.Allocate(ref root.Characters, charCount);
|
||||
for (int i = 0; i < charCount; i++)
|
||||
{
|
||||
var def = authoring.Characters[i];
|
||||
if (def == null) { charArray[i] = default; continue; }
|
||||
DependsOn(def);
|
||||
charArray[i] = new CharacterStatsBlob
|
||||
{
|
||||
Id = (byte)def.Id,
|
||||
MoveSpeed = def.MoveSpeed,
|
||||
TurnRateRadiansPerSec = math.radians(def.TurnRateDegreesPerSec),
|
||||
MaxHealth = def.MaxHealth,
|
||||
Name = def.DisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
var blob = builder.CreateBlobAssetReference<AbilityDatabaseBlob>(Allocator.Persistent);
|
||||
builder.Dispose();
|
||||
AddBlobAsset(ref blob, out _);
|
||||
AddComponent(entity, new AbilityDatabase { Value = blob });
|
||||
|
||||
// Companion entity-ref buffer: per-ability projectile ghost prefab (resolved via GetEntity).
|
||||
var prefabBuffer = AddBuffer<AbilityPrefabElement>(entity);
|
||||
for (int i = 0; i < abilityCount; i++)
|
||||
{
|
||||
var def = authoring.Abilities[i];
|
||||
if (def == null || def.ProjectilePrefab == null) continue;
|
||||
prefabBuffer.Add(new AbilityPrefabElement
|
||||
{
|
||||
Id = (byte)def.Id,
|
||||
Prefab = GetEntity(def.ProjectilePrefab, TransformUsageFlags.Dynamic),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f60b41d8ec8d4c24b4d4f54af919080
|
||||
@@ -0,0 +1,33 @@
|
||||
using ProjectM.Simulation;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Designer-facing definition of one ability. Numeric fields are baked into the AbilityDatabase blob
|
||||
/// (immutable, Burst-fast runtime config); the projectile prefab is baked into the companion
|
||||
/// AbilityPrefabElement buffer (entity refs cannot live inside a blob). UI fields (icon/description)
|
||||
/// are deliberately deferred to a later managed lookup keyed by id.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Project M/Ability Definition", fileName = "Ability_")]
|
||||
public class AbilityDefinition : ScriptableObject
|
||||
{
|
||||
public AbilityId Id = AbilityId.Primary;
|
||||
public string DisplayName = "Ability";
|
||||
|
||||
[Header("Combat")]
|
||||
[Min(0f)] public float Damage = 20f;
|
||||
[Min(0f)] public float ProjectileSpeed = 25f;
|
||||
[Min(0f)] public float Range = 20f;
|
||||
|
||||
[Header("Auto-target assist")]
|
||||
[Min(0f)] public float AutoTargetRange = 12f;
|
||||
[Min(0f)] public float AutoTargetConeDegrees = 35f;
|
||||
|
||||
[Header("Timing")]
|
||||
[Min(1)] public int CooldownTicks = 12;
|
||||
|
||||
[Header("Prefab (baked into the prefab buffer, not the blob)")]
|
||||
public GameObject ProjectilePrefab;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 84b20ca889a744e888c8c3b3b723ec69
|
||||
@@ -0,0 +1,21 @@
|
||||
using ProjectM.Simulation;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Designer-facing definition of a character's base stats (movement + survivability), baked into the
|
||||
/// AbilityDatabase blob and looked up at runtime by CharacterStatsRef. The single source of these
|
||||
/// values - PlayerAuthoring also seeds the player's starting Health from MaxHealth.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Project M/Character Stats Definition", fileName = "Character_")]
|
||||
public class CharacterStatsDefinition : ScriptableObject
|
||||
{
|
||||
public CharacterId Id = CharacterId.Default;
|
||||
public string DisplayName = "Character";
|
||||
|
||||
[Min(0f)] public float MoveSpeed = 6f;
|
||||
[Min(0f)] public float TurnRateDegreesPerSec = 720f;
|
||||
[Min(0f)] public float MaxHealth = 100f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c63201f1fd9f24efd9ccae3e20cd2364
|
||||
@@ -0,0 +1,38 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for the projectile ghost prefab fired by the player's primary ability. Bakes the
|
||||
/// baked-once tunables (<see cref="Projectile.Speed"/>, <see cref="Projectile.Damage"/>,
|
||||
/// <see cref="Projectile.Range"/>) onto the entity; the replicated <c>Direction</c>/<c>SpawnId</c>
|
||||
/// and the integrated <c>DistanceTravelled</c> are left at their default 0 and written at spawn
|
||||
/// time by AbilityFireSystem. Ghost replication and <c>GhostOwner</c> are supplied by the
|
||||
/// GhostAuthoringComponent added on the same prefab GameObject (not added here, nor is Health).
|
||||
/// <c>GetEntity(TransformUsageFlags.Dynamic)</c> ensures a runtime-mutable LocalTransform exists.
|
||||
/// </summary>
|
||||
public class ProjectileAuthoring : MonoBehaviour
|
||||
{
|
||||
[Min(0f)] public float Speed = 25f;
|
||||
[Min(0f)] public float Damage = 20f;
|
||||
[Min(0f)] public float Range = 20f;
|
||||
|
||||
private class ProjectileBaker : Baker<ProjectileAuthoring>
|
||||
{
|
||||
public override void Bake(ProjectileAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
|
||||
// Direction / SpawnId / DistanceTravelled default to 0 — set at spawn by AbilityFireSystem.
|
||||
AddComponent(entity, new Projectile
|
||||
{
|
||||
Speed = authoring.Speed,
|
||||
Damage = authoring.Damage,
|
||||
Range = authoring.Range
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1429bc3b3a1da44e4a11065be0733a8f
|
||||
@@ -0,0 +1,34 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring placed once in the gameplay subscene. Bakes a <see cref="ProjectileSpawner"/>
|
||||
/// singleton holding the projectile ghost prefab entity, which the predicted AbilityFireSystem
|
||||
/// instantiates whenever a player fires. The spawner itself carries no transform (it is a pure
|
||||
/// data singleton) so it is baked with <c>TransformUsageFlags.None</c>, while the referenced
|
||||
/// prefab is baked with <c>TransformUsageFlags.Dynamic</c> so the spawned projectile has a
|
||||
/// runtime-mutable LocalTransform.
|
||||
/// </summary>
|
||||
public class ProjectileSpawnerAuthoring : MonoBehaviour
|
||||
{
|
||||
[Tooltip("The projectile ghost prefab spawned when a player fires.")]
|
||||
public GameObject ProjectilePrefab;
|
||||
|
||||
private class ProjectileSpawnerBaker : Baker<ProjectileSpawnerAuthoring>
|
||||
{
|
||||
public override void Bake(ProjectileSpawnerAuthoring authoring)
|
||||
{
|
||||
// The spawner itself needs no transform; it is a data singleton.
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.None);
|
||||
|
||||
AddComponent(entity, new ProjectileSpawner
|
||||
{
|
||||
Prefab = GetEntity(authoring.ProjectilePrefab, TransformUsageFlags.Dynamic)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa1b7c054d5a043f2801cddf90567acf
|
||||
@@ -0,0 +1,41 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for the training-dummy enemy prefab. Bakes a stationary, damageable auto-target
|
||||
/// candidate: <see cref="TrainingDummyTag"/> marks it for the ability auto-target cone,
|
||||
/// <see cref="Health"/> and <see cref="HitRadius"/> make it a valid projectile hit target, and a
|
||||
/// <see cref="DamageEvent"/> buffer receives server-authoritative hits. Dummies are NOT ghosts and
|
||||
/// carry no <c>GhostOwner</c>, so projectiles never treat them as the firing owner.
|
||||
/// <c>GetEntity(TransformUsageFlags.Dynamic)</c> ensures a runtime LocalTransform for hit tests
|
||||
/// and spawn placement.
|
||||
/// </summary>
|
||||
public class TrainingDummyAuthoring : MonoBehaviour
|
||||
{
|
||||
[Min(0f), Tooltip("Starting and maximum health for the dummy.")]
|
||||
public float MaxHealth = 60f;
|
||||
|
||||
[Min(0f), Tooltip("World-unit radius used by the projectile hit test.")]
|
||||
public float HitRadius = 0.8f;
|
||||
|
||||
private class TrainingDummyBaker : Baker<TrainingDummyAuthoring>
|
||||
{
|
||||
public override void Bake(TrainingDummyAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
|
||||
AddComponent<TrainingDummyTag>(entity);
|
||||
AddComponent(entity, new Health
|
||||
{
|
||||
Current = authoring.MaxHealth,
|
||||
Max = authoring.MaxHealth
|
||||
});
|
||||
AddComponent(entity, new HitRadius { Value = authoring.HitRadius });
|
||||
AddBuffer<DamageEvent>(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c32aebeb7bfbb464898dfee8e6e87e6c
|
||||
@@ -0,0 +1,48 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for the baked <see cref="TrainingDummySpawner"/> singleton. Place this on a single
|
||||
/// GameObject in the gameplay subscene; at runtime the server-only
|
||||
/// <c>TrainingDummySpawnSystem</c> reads the singleton, instantiates <see cref="Count"/> dummies
|
||||
/// laid out along +X from <see cref="Origin"/> at <see cref="Spacing"/> intervals, then destroys
|
||||
/// the singleton so it fires exactly once. The entity itself carries no transform
|
||||
/// (<c>TransformUsageFlags.None</c>); only the referenced <see cref="DummyPrefab"/> needs a
|
||||
/// runtime-mutable LocalTransform (<c>TransformUsageFlags.Dynamic</c>).
|
||||
/// </summary>
|
||||
public class TrainingDummySpawnerAuthoring : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Training dummy prefab to instantiate. Must carry TrainingDummyAuthoring.")]
|
||||
public GameObject DummyPrefab;
|
||||
|
||||
[Min(0)]
|
||||
[Tooltip("How many dummies to spawn.")]
|
||||
public int Count = 3;
|
||||
|
||||
[Min(0f)]
|
||||
[Tooltip("World-unit spacing between consecutive dummies along +X.")]
|
||||
public float Spacing = 3f;
|
||||
|
||||
[Tooltip("World-space position of the first dummy.")]
|
||||
public Vector3 Origin = new Vector3(0, 0, 8);
|
||||
|
||||
private class TrainingDummySpawnerBaker : Baker<TrainingDummySpawnerAuthoring>
|
||||
{
|
||||
public override void Bake(TrainingDummySpawnerAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.None);
|
||||
|
||||
AddComponent(entity, new TrainingDummySpawner
|
||||
{
|
||||
Prefab = GetEntity(authoring.DummyPrefab, TransformUsageFlags.Dynamic),
|
||||
Count = authoring.Count,
|
||||
Spacing = authoring.Spacing,
|
||||
Origin = authoring.Origin
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 51cc543c146d84239ba6dc219221df18
|
||||
@@ -0,0 +1,43 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for an upgrade pickup ghost prefab: a world object that grants one stat modifier to the
|
||||
/// first player that overlaps it (server-authoritative, applied by <c>UpgradePickupSystem</c>) and
|
||||
/// then despawns. Bake the prefab as an interpolated ghost (add a GhostAuthoringComponent) so clients
|
||||
/// see it appear and despawn. <c>GetEntity(TransformUsageFlags.Dynamic)</c> gives it a world transform.
|
||||
/// </summary>
|
||||
public class UpgradePickupAuthoring : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Which stat the granted modifier targets.")]
|
||||
public StatTarget Target = StatTarget.Damage;
|
||||
|
||||
[Tooltip("How the granted modifier combines.")]
|
||||
public ModOp Op = ModOp.Flat;
|
||||
|
||||
[Tooltip("Modifier magnitude: flat amount, or fractional percent (0.1 = +10%).")]
|
||||
public float Value = 10f;
|
||||
|
||||
[Tooltip("Overlap radius (world units) for the player pickup test.")]
|
||||
[Min(0f)] public float HitRadius = 1f;
|
||||
|
||||
private class UpgradePickupBaker : Baker<UpgradePickupAuthoring>
|
||||
{
|
||||
public override void Bake(UpgradePickupAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
AddComponent(entity, new UpgradePickup
|
||||
{
|
||||
Target = (byte)authoring.Target,
|
||||
Op = (byte)authoring.Op,
|
||||
Value = authoring.Value,
|
||||
SourceId = 0u,
|
||||
});
|
||||
AddComponent(entity, new HitRadius { Value = authoring.HitRadius });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 07e6d40378fcb43c5be706ef96cb4bb2
|
||||
@@ -0,0 +1,48 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for the baked <see cref="UpgradePickupSpawner"/> singleton (mirrors
|
||||
/// TrainingDummySpawnerAuthoring). Place on a single GameObject in the gameplay subscene; the
|
||||
/// server-only <c>UpgradePickupSpawnSystem</c> reads it, spawns <see cref="Count"/> pickups along +X
|
||||
/// from <see cref="Origin"/> at <see cref="Spacing"/> intervals, then destroys the singleton so it
|
||||
/// fires exactly once. The entity carries no transform; only the prefab needs a runtime transform.
|
||||
/// </summary>
|
||||
public class UpgradePickupSpawnerAuthoring : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Upgrade pickup prefab to instantiate. Must carry UpgradePickupAuthoring.")]
|
||||
public GameObject PickupPrefab;
|
||||
|
||||
[Min(0)]
|
||||
[Tooltip("How many pickups to spawn.")]
|
||||
public int Count = 2;
|
||||
|
||||
[Min(0f)]
|
||||
[Tooltip("World-unit spacing between consecutive pickups along +X.")]
|
||||
public float Spacing = 3f;
|
||||
|
||||
[Tooltip("World-space position of the first pickup.")]
|
||||
public Vector3 Origin = new Vector3(-4f, 0f, 6f);
|
||||
|
||||
private class UpgradePickupSpawnerBaker : Baker<UpgradePickupSpawnerAuthoring>
|
||||
{
|
||||
public override void Bake(UpgradePickupSpawnerAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.None);
|
||||
|
||||
AddComponent(entity, new UpgradePickupSpawner
|
||||
{
|
||||
Prefab = authoring.PickupPrefab != null
|
||||
? GetEntity(authoring.PickupPrefab, TransformUsageFlags.Dynamic)
|
||||
: Entity.Null,
|
||||
Count = authoring.Count,
|
||||
Spacing = authoring.Spacing,
|
||||
Origin = authoring.Origin,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e367eb55e2c2248f18be10d6c3c9ad67
|
||||
@@ -1,21 +1,32 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for the player ghost prefab. Bakes the gameplay components onto the entity and
|
||||
/// exposes movement tunables for designers. Ghost replication, <c>GhostOwner</c> and
|
||||
/// AutoCommandTarget are supplied by the GhostAuthoringComponent added on the same prefab
|
||||
/// GameObject. <c>GetEntity(TransformUsageFlags.Dynamic)</c> ensures a runtime-mutable
|
||||
/// LocalTransform exists.
|
||||
/// Authoring for the player ghost prefab. As of M3 the numeric tunables live in data
|
||||
/// (<see cref="CharacterStatsDefinition"/> / <see cref="AbilityDefinition"/> ScriptableObjects);
|
||||
/// this authoring only selects which definitions the player uses and bakes the light id refs, the
|
||||
/// (empty) replicated modifier buffer, and the zeroed effective-stat components that
|
||||
/// StatRecomputeSystem fills each predicted tick. Health is seeded from the character definition's
|
||||
/// MaxHealth (single source). Ghost replication, <c>GhostOwner</c> and AutoCommandTarget come from
|
||||
/// the GhostAuthoringComponent on the same prefab GameObject; <c>GetEntity(TransformUsageFlags.Dynamic)</c>
|
||||
/// ensures a runtime-mutable LocalTransform exists.
|
||||
/// </summary>
|
||||
public class PlayerAuthoring : MonoBehaviour
|
||||
{
|
||||
[Min(0f)] public float MoveSpeed = 6f;
|
||||
[Min(0f)] public float TurnRateDegreesPerSec = 720f;
|
||||
[Tooltip("Character-stats definition (move speed, turn rate, max health). Single source of those values.")]
|
||||
public CharacterStatsDefinition Character;
|
||||
|
||||
[Tooltip("Ability definition occupying the player's primary slot.")]
|
||||
public AbilityDefinition PrimaryAbility;
|
||||
|
||||
[Header("Fallbacks (used only if a definition above is unassigned)")]
|
||||
[Min(0f)] public float FallbackMaxHealth = 100f;
|
||||
|
||||
/// <summary>Projectile hit-test radius for the player as a damageable target, in world units.</summary>
|
||||
[Min(0f)] public float HitRadius = 0.6f;
|
||||
|
||||
private class PlayerBaker : Baker<PlayerAuthoring>
|
||||
{
|
||||
@@ -23,14 +34,38 @@ namespace ProjectM.Authoring
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
|
||||
// Re-bake when a referenced definition's serialized values change.
|
||||
if (authoring.Character != null) DependsOn(authoring.Character);
|
||||
if (authoring.PrimaryAbility != null) DependsOn(authoring.PrimaryAbility);
|
||||
|
||||
byte characterId = authoring.Character != null
|
||||
? (byte)authoring.Character.Id : (byte)CharacterId.Default;
|
||||
byte abilityId = authoring.PrimaryAbility != null
|
||||
? (byte)authoring.PrimaryAbility.Id : (byte)AbilityId.Primary;
|
||||
float maxHealth = authoring.Character != null
|
||||
? authoring.Character.MaxHealth : authoring.FallbackMaxHealth;
|
||||
|
||||
AddComponent<PlayerTag>(entity);
|
||||
AddComponent(entity, new PlayerMoveStats
|
||||
{
|
||||
MoveSpeed = authoring.MoveSpeed,
|
||||
TurnRateRadiansPerSec = math.radians(authoring.TurnRateDegreesPerSec)
|
||||
});
|
||||
AddComponent<PlayerFacing>(entity);
|
||||
AddComponent<PlayerInput>(entity);
|
||||
|
||||
// Data-driven stat refs (replace M2's inlined PlayerMoveStats / AbilityStats values).
|
||||
AddComponent(entity, new CharacterStatsRef { Id = characterId });
|
||||
AddComponent(entity, new AbilityRef { Id = abilityId });
|
||||
|
||||
// Effective stats: zeroed at bake, recomputed every predicted tick by StatRecomputeSystem.
|
||||
AddComponent(entity, new EffectiveAbilityStats());
|
||||
AddComponent(entity, new EffectiveCharacterStats());
|
||||
|
||||
// Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative).
|
||||
AddBuffer<StatModifier>(entity);
|
||||
|
||||
// Combat: server-authoritative health (Current replicated for display), the player's
|
||||
// damageable hit radius, predicted cooldown state, and the per-tick damage inbox.
|
||||
AddComponent(entity, new Health { Current = maxHealth, Max = maxHealth });
|
||||
AddComponent(entity, new HitRadius { Value = authoring.HitRadius });
|
||||
AddComponent<AbilityCooldown>(entity);
|
||||
AddBuffer<DamageEvent>(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 626396bc0d4204603abfd345e6e4f21d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,185 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using Unity.NetCode.LowLevel;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-only predicted-spawn classifier for projectiles. When a predicted client fires, it
|
||||
/// locally spawns a predicted projectile ghost; later the server's authoritative spawn arrives in
|
||||
/// the <see cref="GhostSpawnQueue"/>. This system pairs the incoming server ghost with the matching
|
||||
/// locally predicted entity so netcode reconciles them instead of double-spawning. The match key is
|
||||
/// <see cref="Projectile.SpawnId"/> — a deterministic <c>(ownerNetId << 16) | absoluteFireCount</c>
|
||||
/// value computed identically on client and server, replicated as a <c>[GhostField]</c> so it is
|
||||
/// present in snapshot history and readable here via <see cref="SnapshotDataBufferComponentLookup"/>.
|
||||
/// Mirrors the official Netcode HelloNetcode 02_PredictedSpawning GrenadeClassificationSystem,
|
||||
/// with GrenadeData→<see cref="Projectile"/> and GrenadeSpawner→<see cref="ProjectileSpawner"/>.
|
||||
/// Runs after the built-in <see cref="GhostSpawnClassificationSystem"/> (so any owner-predicted
|
||||
/// default classification has already had a pass) and before the OrderLast
|
||||
/// <c>DefaultGhostSpawnClassificationSystem</c> (so entries this system does NOT match still fall
|
||||
/// through to the spawn-tick-window fallback).
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(GhostSpawnClassificationSystemGroup))]
|
||||
[UpdateAfter(typeof(GhostSpawnClassificationSystem))]
|
||||
[CreateAfter(typeof(GhostCollectionSystem))]
|
||||
[CreateAfter(typeof(GhostReceiveSystem))]
|
||||
// NOTE: intentionally NOT [BurstCompile]d. The cross-assembly generic
|
||||
// SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory<T>() trips a Burst
|
||||
// internal compiler error (type-hash resolution) on Netcode 1.13.2. Classification only runs when
|
||||
// ghost spawns are received (a cold path, not the prediction loop), so a managed job is fine here.
|
||||
public partial struct ProjectileClassificationSystem : ISystem
|
||||
{
|
||||
SnapshotDataLookupHelper m_SnapshotDataLookupHelper;
|
||||
BufferLookup<PredictedGhostSpawn> m_PredictedGhostSpawnLookup;
|
||||
ComponentLookup<Projectile> m_ProjectileLookup;
|
||||
|
||||
/// <summary>
|
||||
/// Resolved once in <see cref="OnUpdate"/>: the ghost-collection index of our projectile prefab.
|
||||
/// -1 until the <see cref="GhostCollectionPrefab"/> buffer has been populated and scanned.
|
||||
/// </summary>
|
||||
int m_GhostType;
|
||||
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
// Match the built-in GhostSpawnClassificationSystem / DefaultGhostSpawnClassificationSystem:
|
||||
// in a single-world host (a world flagged GameClient AND GameServer) there is no real client
|
||||
// snapshot history to classify against, so the package's classification systems disable
|
||||
// themselves. We do the same so we never run alone in that scenario after the system we
|
||||
// UpdateAfter has switched itself off. (For Project M's standard separate ClientWorld +
|
||||
// ServerWorld over IPC, IsHost() is false and this guard is a no-op.)
|
||||
if (state.WorldUnmanaged.IsHost())
|
||||
{
|
||||
state.Enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the snapshot lookup helper from the two collection singletons. CreateAfter on
|
||||
// GhostCollectionSystem + GhostReceiveSystem guarantees both singletons exist by now.
|
||||
m_SnapshotDataLookupHelper = new SnapshotDataLookupHelper(
|
||||
ref state,
|
||||
SystemAPI.GetSingletonEntity<GhostCollection>(),
|
||||
SystemAPI.GetSingletonEntity<SpawnedGhostEntityMap>());
|
||||
|
||||
m_PredictedGhostSpawnLookup = state.GetBufferLookup<PredictedGhostSpawn>(true);
|
||||
m_ProjectileLookup = state.GetComponentLookup<Projectile>(true);
|
||||
|
||||
state.RequireForUpdate<GhostSpawnQueue>();
|
||||
state.RequireForUpdate<PredictedGhostSpawnList>();
|
||||
state.RequireForUpdate<NetworkId>();
|
||||
state.RequireForUpdate<ProjectileSpawner>();
|
||||
|
||||
m_GhostType = -1;
|
||||
}
|
||||
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
// Resolve our projectile ghost-type index once by scanning the ghost-collection prefab
|
||||
// buffer for the spawner's prefab entity. The collection is populated only after the ghost
|
||||
// prefabs have loaded, so retry each tick until found.
|
||||
if (m_GhostType == -1)
|
||||
{
|
||||
var projectilePrefab = SystemAPI.GetSingleton<ProjectileSpawner>().Prefab;
|
||||
var ghostCollection = SystemAPI.GetSingletonEntity<GhostCollection>();
|
||||
var prefabs = SystemAPI.GetBuffer<GhostCollectionPrefab>(ghostCollection);
|
||||
for (int i = 0; i < prefabs.Length; ++i)
|
||||
{
|
||||
if (prefabs[i].GhostPrefab == projectilePrefab)
|
||||
{
|
||||
m_GhostType = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (m_GhostType == -1)
|
||||
return;
|
||||
}
|
||||
|
||||
m_SnapshotDataLookupHelper.Update(ref state);
|
||||
m_PredictedGhostSpawnLookup.Update(ref state);
|
||||
m_ProjectileLookup.Update(ref state);
|
||||
|
||||
// SystemAPI is a system-context-only facade and cannot be used inside the IJobEntity; resolve
|
||||
// the predicted-spawn-list singleton entity here and pass it into the job (this mirrors the
|
||||
// built-in DefaultGhostSpawnClassificationJob.spawnListEntity pattern).
|
||||
var classificationJob = new ProjectileClassificationJob
|
||||
{
|
||||
GhostType = m_GhostType,
|
||||
SnapshotDataLookup = m_SnapshotDataLookupHelper.CreateSnapshotBufferLookup(),
|
||||
PredictedSpawnListEntity = SystemAPI.GetSingletonEntity<PredictedGhostSpawnList>(),
|
||||
PredictedGhostSpawnLookup = m_PredictedGhostSpawnLookup,
|
||||
ProjectileLookup = m_ProjectileLookup,
|
||||
};
|
||||
state.Dependency = classificationJob.Schedule(state.Dependency);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For each newly received server spawn in a <see cref="GhostSpawnQueue"/>, attempts to find a
|
||||
/// locally predicted projectile with the same <see cref="Projectile.SpawnId"/> read out of
|
||||
/// snapshot history. On a match it points the queued spawn at the predicted entity (so netcode
|
||||
/// adopts it instead of instantiating a duplicate), marks the entry classified, and removes that
|
||||
/// predicted entry from the list. Entries this system does NOT match are left untouched so the
|
||||
/// OrderLast <c>DefaultGhostSpawnClassificationSystem</c> fallback can still try the spawn-tick
|
||||
/// window match.
|
||||
/// </summary>
|
||||
[WithAll(typeof(GhostSpawnQueue))]
|
||||
partial struct ProjectileClassificationJob : IJobEntity
|
||||
{
|
||||
public int GhostType;
|
||||
public SnapshotDataBufferComponentLookup SnapshotDataLookup;
|
||||
|
||||
// Resolved in OnUpdate (SystemAPI is unavailable inside a job). A single Entity field, so it
|
||||
// is NOT marked [ReadOnly].
|
||||
public Entity PredictedSpawnListEntity;
|
||||
|
||||
[ReadOnly] public BufferLookup<PredictedGhostSpawn> PredictedGhostSpawnLookup;
|
||||
[ReadOnly] public ComponentLookup<Projectile> ProjectileLookup;
|
||||
|
||||
// 'data' is taken by value (NOT 'in') because TryGetComponentDataFromSnapshotHistory needs a
|
||||
// mutable 'ref DynamicBuffer<SnapshotDataBuffer>'. The built-in GhostSpawnClassification uses
|
||||
// 'in' only because it never calls that ref overload — do not copy that here.
|
||||
public void Execute(DynamicBuffer<GhostSpawnBuffer> newSpawns, DynamicBuffer<SnapshotDataBuffer> data)
|
||||
{
|
||||
var predictedSpawnList = PredictedGhostSpawnLookup[PredictedSpawnListEntity];
|
||||
|
||||
for (int i = 0; i < newSpawns.Length; ++i)
|
||||
{
|
||||
ref var newSpawn = ref newSpawns.ElementAt(i);
|
||||
|
||||
// Only classify our own ghost type, and only predicted spawns that have not already
|
||||
// been matched/claimed (PredictedSpawnEntity == Null && !HasClassifiedPredictedSpawn)
|
||||
// — leave everything else to the defaults.
|
||||
if (newSpawn.GhostType != GhostType)
|
||||
continue;
|
||||
if (newSpawn.SpawnType != GhostSpawnBuffer.Type.Predicted ||
|
||||
newSpawn.HasClassifiedPredictedSpawn ||
|
||||
newSpawn.PredictedSpawnEntity != Entity.Null)
|
||||
continue;
|
||||
|
||||
if (!SnapshotDataLookup.TryGetComponentDataFromSnapshotHistory(
|
||||
newSpawn.GhostType, data, out Projectile incoming, i))
|
||||
continue;
|
||||
|
||||
for (int j = 0; j < predictedSpawnList.Length; ++j)
|
||||
{
|
||||
if (predictedSpawnList[j].ghostType != GhostType)
|
||||
continue;
|
||||
|
||||
var predictedEntity = predictedSpawnList[j].entity;
|
||||
if (incoming.SpawnId == ProjectileLookup[predictedEntity].SpawnId)
|
||||
{
|
||||
// Claim the decision ONLY on a real match, so non-matches still fall through
|
||||
// to the OrderLast default classifier (matches the official sample).
|
||||
newSpawn.PredictedSpawnEntity = predictedEntity;
|
||||
newSpawn.HasClassifiedPredictedSpawn = true;
|
||||
predictedSpawnList.RemoveAtSwapBack(j);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 97e6942342fcf4de5b0ede9f2a33ed5b
|
||||
@@ -0,0 +1,85 @@
|
||||
#if UNITY_EDITOR
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// EDITOR-ONLY validation hook for driving the local player's <see cref="PlayerInput"/> without a
|
||||
/// real input device or a focused Game view. The Unity Input System ignores injected/real device
|
||||
/// input while the Game view is unfocused, which makes headless (MCP <c>execute_code</c>) or
|
||||
/// automated fire/move validation impossible through <see cref="PlayerInputGatherSystem"/> alone.
|
||||
/// <para>
|
||||
/// This system runs in <see cref="GhostInputSystemGroup"/> immediately AFTER the real gather and,
|
||||
/// when <see cref="Active"/> is set, overwrites the locally-owned player's input from static fields
|
||||
/// you can poke from a debugger / <c>execute_code</c> / an editor button. Because it writes the same
|
||||
/// <see cref="PlayerInput"/> the gather does, it drives the authentic command → prediction →
|
||||
/// AbilityFireSystem pipeline (not a shortcut), so it validates the real fire/move/auto-target path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Entirely wrapped in <c>#if UNITY_EDITOR</c>: it does not exist in player builds. Pair with
|
||||
/// <c>Application.runInBackground = true</c> so the unfocused editor keeps ticking. Usage from
|
||||
/// <c>execute_code</c>: <c>ProjectM.Client.DebugInputInjectionSystem.Fire();</c> (one shot),
|
||||
/// <c>...SetMove(0f, 1f);</c> (hold a move heading), <c>...SetAim(1f, 0f);</c>, <c>...Stop();</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[UpdateInGroup(typeof(GhostInputSystemGroup))]
|
||||
[UpdateAfter(typeof(PlayerInputGatherSystem))]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
public partial class DebugInputInjectionSystem : SystemBase
|
||||
{
|
||||
/// <summary>While true, this system overrides the local player's gathered input each frame.</summary>
|
||||
public static bool Active;
|
||||
|
||||
/// <summary>Movement heading applied to PlayerInput.Move while <see cref="Active"/>.</summary>
|
||||
public static float2 Move;
|
||||
|
||||
/// <summary>Aim vector applied to PlayerInput.Aim while <see cref="Active"/> (zero = face movement).</summary>
|
||||
public static float2 Aim;
|
||||
|
||||
/// <summary>Frames remaining to hold the Fire event. Each held frame raises Fire.Set(); holding
|
||||
/// across several frames spans multiple network ticks so the one-shot event reliably reaches the
|
||||
/// command buffer (a single-frame pulse can be lost across the frame→tick boundary). 0 = idle.</summary>
|
||||
public static int FireFrames;
|
||||
|
||||
/// <summary>Convenience: hold Fire for the next <paramref name="frames"/> frames (also enables
|
||||
/// override). The ability cooldown still gates how many shots actually result.</summary>
|
||||
public static void Fire(int frames = 10) { Active = true; FireFrames = math.max(FireFrames, frames); }
|
||||
|
||||
/// <summary>Convenience: hold a planar move heading (also enables override).</summary>
|
||||
public static void SetMove(float x, float z) { Active = true; Move = new float2(x, z); }
|
||||
|
||||
/// <summary>Convenience: hold an aim direction (also enables override).</summary>
|
||||
public static void SetAim(float x, float z) { Active = true; Aim = new float2(x, z); }
|
||||
|
||||
/// <summary>Convenience: stop overriding and clear all injected input.</summary>
|
||||
public static void Stop() { Active = false; Move = default; Aim = default; FireFrames = 0; }
|
||||
|
||||
protected override void OnCreate()
|
||||
{
|
||||
RequireForUpdate<PlayerInput>();
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (!Active)
|
||||
return;
|
||||
|
||||
bool fire = FireFrames > 0;
|
||||
if (FireFrames > 0) FireFrames--;
|
||||
float2 move = Move;
|
||||
float2 aim = Aim;
|
||||
|
||||
foreach (var input in SystemAPI.Query<RefRW<PlayerInput>>().WithAll<GhostOwnerIsLocal>())
|
||||
{
|
||||
input.ValueRW.Move = move;
|
||||
input.ValueRW.Aim = aim;
|
||||
if (fire)
|
||||
input.ValueRW.Fire.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 51213eca1bfe84a16837d5755c334101
|
||||
@@ -6,62 +6,73 @@ using Unity.NetCode;
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-only twin-stick input gather. Samples the Input System once per frame (WASD /
|
||||
/// left-stick -> Move, right-stick -> Aim) and writes <see cref="PlayerInput"/> on the
|
||||
/// locally-owned player ghost (filtered to <see cref="GhostOwnerIsLocal"/>). Runs in
|
||||
/// <see cref="GhostInputSystemGroup"/> — NOT the prediction loop — so devices are read once per
|
||||
/// frame, never re-read during rollback. Implemented as a non-Burst <see cref="ISystem"/>
|
||||
/// because it reads the managed Input System.
|
||||
/// Client-only twin-stick input gather. Samples the new Input System action map (the generated
|
||||
/// <c>ProjectMInput</c> wrapper over <c>Assets/Settings/Project M Input.inputactions</c>) once per
|
||||
/// frame and writes <see cref="PlayerInput"/> on the locally-owned player ghost (filtered to
|
||||
/// <see cref="GhostOwnerIsLocal"/>). Runs in <see cref="GhostInputSystemGroup"/> — NOT the
|
||||
/// prediction loop — so devices are read once per frame, never re-read during rollback.
|
||||
/// <para>
|
||||
/// NOTE: the Input System device types are fully qualified rather than imported via
|
||||
/// <c>using UnityEngine.InputSystem;</c> on purpose — that namespace also defines a
|
||||
/// <c>PlayerInput</c> type which would collide with <see cref="ProjectM.Simulation.PlayerInput"/>
|
||||
/// and make the Entities source generator bind <c>RefRW<PlayerInput></c> to the managed
|
||||
/// class (a spurious CS8377 "must be unmanaged").
|
||||
/// Implemented as a managed <see cref="SystemBase"/> (not a Burst <c>ISystem</c>) because it holds
|
||||
/// and reads the managed Input System wrapper. Fire is an <see cref="InputEvent"/>: the event field
|
||||
/// is reset each frame and raised via <c>Set()</c> on the press edge, so a single click fires
|
||||
/// exactly once; netcode accumulates the absolute <c>Count</c> into the command buffer across the
|
||||
/// frame→tick boundary (read back in <c>AbilityFireSystem</c> as the predicted-spawn key).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// NOTE: Input System types are fully qualified (e.g. <c>UnityEngine.Vector2</c>) and
|
||||
/// <c>using UnityEngine.InputSystem;</c> is intentionally omitted — that namespace defines a
|
||||
/// <c>PlayerInput</c> type that collides with <see cref="ProjectM.Simulation.PlayerInput"/> and
|
||||
/// makes the Entities generator bind <c>RefRW<PlayerInput></c> to the managed class (a
|
||||
/// spurious CS8377). The generated <c>ProjectMInput</c> wrapper lives in this assembly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[UpdateInGroup(typeof(GhostInputSystemGroup))]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
public partial struct PlayerInputGatherSystem : ISystem
|
||||
public partial class PlayerInputGatherSystem : SystemBase
|
||||
{
|
||||
public void OnCreate(ref SystemState state)
|
||||
private ProjectMInput _controls;
|
||||
|
||||
protected override void OnCreate()
|
||||
{
|
||||
state.RequireForUpdate<PlayerInput>();
|
||||
RequireForUpdate<PlayerInput>();
|
||||
_controls = new ProjectMInput();
|
||||
_controls.Gameplay.Enable();
|
||||
}
|
||||
|
||||
public void OnUpdate(ref SystemState state)
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
float2 move = float2.zero;
|
||||
float2 aim = float2.zero;
|
||||
|
||||
var keyboard = UnityEngine.InputSystem.Keyboard.current;
|
||||
if (keyboard != null)
|
||||
if (_controls != null)
|
||||
{
|
||||
if (keyboard.wKey.isPressed) move.y += 1f;
|
||||
if (keyboard.sKey.isPressed) move.y -= 1f;
|
||||
if (keyboard.dKey.isPressed) move.x += 1f;
|
||||
if (keyboard.aKey.isPressed) move.x -= 1f;
|
||||
_controls.Gameplay.Disable();
|
||||
_controls.Dispose();
|
||||
_controls = null;
|
||||
}
|
||||
}
|
||||
|
||||
var gamepad = UnityEngine.InputSystem.Gamepad.current;
|
||||
if (gamepad != null)
|
||||
{
|
||||
float2 leftStick = gamepad.leftStick.ReadValue();
|
||||
if (math.lengthsq(leftStick) > math.lengthsq(move))
|
||||
move = leftStick;
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
var gameplay = _controls.Gameplay;
|
||||
|
||||
aim = gamepad.rightStick.ReadValue();
|
||||
}
|
||||
float2 move = (float2)gameplay.Move.ReadValue<UnityEngine.Vector2>();
|
||||
float2 aim = (float2)gameplay.Aim.ReadValue<UnityEngine.Vector2>();
|
||||
|
||||
// Right-stick deadzone: a resting stick yields zero Aim so PlayerAimSystem falls back to
|
||||
// the movement heading (controller-first directional aim).
|
||||
// Right-stick deadzone: a resting stick yields zero Aim so PlayerAimSystem falls back to the
|
||||
// movement heading (controller-first directional aim).
|
||||
if (math.lengthsq(aim) < 0.04f)
|
||||
aim = float2.zero;
|
||||
|
||||
bool firePressed = gameplay.Fire.WasPressedThisFrame();
|
||||
|
||||
foreach (var input in SystemAPI.Query<RefRW<PlayerInput>>().WithAll<GhostOwnerIsLocal>())
|
||||
{
|
||||
input.ValueRW.Move = move;
|
||||
input.ValueRW.Aim = aim;
|
||||
|
||||
// Reset the per-frame event, then raise it on the press edge. Netcode latches the
|
||||
// absolute Count into the command buffer; AbilityFireSystem reads it as the SpawnId key.
|
||||
input.ValueRW.Fire = default;
|
||||
if (firePressed)
|
||||
input.ValueRW.Fire.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was auto-generated by com.unity.inputsystem:InputActionCodeGenerator
|
||||
// version 1.19.0
|
||||
// from Assets/Settings/Project M Input.inputactions
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Provides programmatic access to <see cref="InputActionAsset" />, <see cref="InputActionMap" />, <see cref="InputAction" /> and <see cref="InputControlScheme" /> instances defined in asset "Assets/Settings/Project M Input.inputactions".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class is source generated and any manual edits will be discarded if the associated asset is reimported or modified.
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// using namespace UnityEngine;
|
||||
/// using UnityEngine.InputSystem;
|
||||
///
|
||||
/// // Example of using an InputActionMap named "Player" from a UnityEngine.MonoBehaviour implementing callback interface.
|
||||
/// public class Example : MonoBehaviour, MyActions.IPlayerActions
|
||||
/// {
|
||||
/// private MyActions_Actions m_Actions; // Source code representation of asset.
|
||||
/// private MyActions_Actions.PlayerActions m_Player; // Source code representation of action map.
|
||||
///
|
||||
/// void Awake()
|
||||
/// {
|
||||
/// m_Actions = new MyActions_Actions(); // Create asset object.
|
||||
/// m_Player = m_Actions.Player; // Extract action map object.
|
||||
/// m_Player.AddCallbacks(this); // Register callback interface IPlayerActions.
|
||||
/// }
|
||||
///
|
||||
/// void OnDestroy()
|
||||
/// {
|
||||
/// m_Actions.Dispose(); // Destroy asset object.
|
||||
/// }
|
||||
///
|
||||
/// void OnEnable()
|
||||
/// {
|
||||
/// m_Player.Enable(); // Enable all actions within map.
|
||||
/// }
|
||||
///
|
||||
/// void OnDisable()
|
||||
/// {
|
||||
/// m_Player.Disable(); // Disable all actions within map.
|
||||
/// }
|
||||
///
|
||||
/// #region Interface implementation of MyActions.IPlayerActions
|
||||
///
|
||||
/// // Invoked when "Move" action is either started, performed or canceled.
|
||||
/// public void OnMove(InputAction.CallbackContext context)
|
||||
/// {
|
||||
/// Debug.Log($"OnMove: {context.ReadValue<Vector2>()}");
|
||||
/// }
|
||||
///
|
||||
/// // Invoked when "Attack" action is either started, performed or canceled.
|
||||
/// public void OnAttack(InputAction.CallbackContext context)
|
||||
/// {
|
||||
/// Debug.Log($"OnAttack: {context.ReadValue<float>()}");
|
||||
/// }
|
||||
///
|
||||
/// #endregion
|
||||
/// }
|
||||
/// </code>
|
||||
/// </example>
|
||||
public partial class @ProjectMInput: IInputActionCollection2, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides access to the underlying asset instance.
|
||||
/// </summary>
|
||||
public InputActionAsset asset { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a new instance.
|
||||
/// </summary>
|
||||
public @ProjectMInput()
|
||||
{
|
||||
asset = InputActionAsset.FromJson(@"{
|
||||
""version"": 1,
|
||||
""name"": ""Project M Input"",
|
||||
""maps"": [
|
||||
{
|
||||
""name"": ""Gameplay"",
|
||||
""id"": ""69723709-3120-49dd-bbf0-91ac92ad8582"",
|
||||
""actions"": [
|
||||
{
|
||||
""name"": ""Move"",
|
||||
""type"": ""Value"",
|
||||
""id"": ""2982f936-538e-4a37-a100-8eb7a2a88e4b"",
|
||||
""expectedControlType"": ""Vector2"",
|
||||
""processors"": """",
|
||||
""interactions"": """",
|
||||
""initialStateCheck"": true
|
||||
},
|
||||
{
|
||||
""name"": ""Aim"",
|
||||
""type"": ""Value"",
|
||||
""id"": ""a1b2c3d4-0a1m-4a1m-8a1m-000000000001"",
|
||||
""expectedControlType"": ""Vector2"",
|
||||
""processors"": """",
|
||||
""interactions"": """",
|
||||
""initialStateCheck"": true
|
||||
},
|
||||
{
|
||||
""name"": ""Fire"",
|
||||
""type"": ""Button"",
|
||||
""id"": ""a1b2c3d4-0f1r-4f1r-8f1r-000000000002"",
|
||||
""expectedControlType"": ""Button"",
|
||||
""processors"": """",
|
||||
""interactions"": """",
|
||||
""initialStateCheck"": false
|
||||
}
|
||||
],
|
||||
""bindings"": [
|
||||
{
|
||||
""name"": """",
|
||||
""id"": ""c3b1e700-eea3-426c-863d-36403d537af3"",
|
||||
""path"": ""<Gamepad>/leftStick"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Gamepad"",
|
||||
""action"": ""Move"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": false
|
||||
},
|
||||
{
|
||||
""name"": ""WASD"",
|
||||
""id"": ""cc5f8773-3f87-4e89-926e-2835221cc71b"",
|
||||
""path"": ""Dpad"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": """",
|
||||
""action"": ""Move"",
|
||||
""isComposite"": true,
|
||||
""isPartOfComposite"": false
|
||||
},
|
||||
{
|
||||
""name"": ""up"",
|
||||
""id"": ""cc98fd24-5e37-4b4a-8fdc-9a1c21bc1e09"",
|
||||
""path"": ""<Keyboard>/w"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Keyboard&Mouse"",
|
||||
""action"": ""Move"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": true
|
||||
},
|
||||
{
|
||||
""name"": ""up"",
|
||||
""id"": ""59f74329-7d63-4bf5-b3bc-3af75462894f"",
|
||||
""path"": ""<Keyboard>/upArrow"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Keyboard&Mouse"",
|
||||
""action"": ""Move"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": true
|
||||
},
|
||||
{
|
||||
""name"": ""down"",
|
||||
""id"": ""624a58a3-f4ec-479f-8e1b-3b54ffaa7d8e"",
|
||||
""path"": ""<Keyboard>/s"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Keyboard&Mouse"",
|
||||
""action"": ""Move"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": true
|
||||
},
|
||||
{
|
||||
""name"": ""down"",
|
||||
""id"": ""2d3501d8-5759-4d58-a909-a5ef3aa51842"",
|
||||
""path"": ""<Keyboard>/downArrow"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Keyboard&Mouse"",
|
||||
""action"": ""Move"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": true
|
||||
},
|
||||
{
|
||||
""name"": ""left"",
|
||||
""id"": ""14dbcdaf-402d-496b-9798-54b770c4a826"",
|
||||
""path"": ""<Keyboard>/a"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Keyboard&Mouse"",
|
||||
""action"": ""Move"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": true
|
||||
},
|
||||
{
|
||||
""name"": ""left"",
|
||||
""id"": ""2f4809ce-93a5-44f7-802d-6ac61f1e03e2"",
|
||||
""path"": ""<Keyboard>/leftArrow"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Keyboard&Mouse"",
|
||||
""action"": ""Move"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": true
|
||||
},
|
||||
{
|
||||
""name"": ""right"",
|
||||
""id"": ""de79a6ae-cf9d-4536-b851-b630e3344d04"",
|
||||
""path"": ""<Keyboard>/d"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Keyboard&Mouse"",
|
||||
""action"": ""Move"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": true
|
||||
},
|
||||
{
|
||||
""name"": ""right"",
|
||||
""id"": ""5bfaeff9-d16a-4fca-b43d-1d6d6b442bc8"",
|
||||
""path"": ""<Keyboard>/rightArrow"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Keyboard&Mouse"",
|
||||
""action"": ""Move"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": true
|
||||
},
|
||||
{
|
||||
""name"": """",
|
||||
""id"": ""401d3950-45d9-4839-b287-14c2b3902c67"",
|
||||
""path"": ""<XRController>/{Primary2DAxis}"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": ""XR"",
|
||||
""action"": ""Move"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": false
|
||||
},
|
||||
{
|
||||
""name"": """",
|
||||
""id"": ""b23bd67c-a1c3-44a5-a73b-c824416534fa"",
|
||||
""path"": ""<Joystick>/stick"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": ""Joystick"",
|
||||
""action"": ""Move"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": false
|
||||
},
|
||||
{
|
||||
""name"": """",
|
||||
""id"": ""d1e2f3a4-aim0-4001-8001-000000000011"",
|
||||
""path"": ""<Gamepad>/rightStick"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Gamepad"",
|
||||
""action"": ""Aim"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": false
|
||||
},
|
||||
{
|
||||
""name"": """",
|
||||
""id"": ""d1e2f3a4-fir0-4001-8001-000000000021"",
|
||||
""path"": ""<Gamepad>/rightTrigger"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Gamepad"",
|
||||
""action"": ""Fire"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": false
|
||||
},
|
||||
{
|
||||
""name"": """",
|
||||
""id"": ""d1e2f3a4-fir0-4001-8001-000000000022"",
|
||||
""path"": ""<Mouse>/leftButton"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Keyboard&Mouse"",
|
||||
""action"": ""Fire"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": false
|
||||
},
|
||||
{
|
||||
""name"": """",
|
||||
""id"": ""d1e2f3a4-fir0-4001-8001-000000000023"",
|
||||
""path"": ""<Keyboard>/space"",
|
||||
""interactions"": """",
|
||||
""processors"": """",
|
||||
""groups"": "";Keyboard&Mouse"",
|
||||
""action"": ""Fire"",
|
||||
""isComposite"": false,
|
||||
""isPartOfComposite"": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
""controlSchemes"": []
|
||||
}");
|
||||
// Gameplay
|
||||
m_Gameplay = asset.FindActionMap("Gameplay", throwIfNotFound: true);
|
||||
m_Gameplay_Move = m_Gameplay.FindAction("Move", throwIfNotFound: true);
|
||||
m_Gameplay_Aim = m_Gameplay.FindAction("Aim", throwIfNotFound: true);
|
||||
m_Gameplay_Fire = m_Gameplay.FindAction("Fire", throwIfNotFound: true);
|
||||
}
|
||||
|
||||
~@ProjectMInput()
|
||||
{
|
||||
UnityEngine.Debug.Assert(!m_Gameplay.enabled, "This will cause a leak and performance issues, ProjectMInput.Gameplay.Disable() has not been called.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroys this asset and all associated <see cref="InputAction"/> instances.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
UnityEngine.Object.Destroy(asset);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionAsset.bindingMask" />
|
||||
public InputBinding? bindingMask
|
||||
{
|
||||
get => asset.bindingMask;
|
||||
set => asset.bindingMask = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionAsset.devices" />
|
||||
public ReadOnlyArray<InputDevice>? devices
|
||||
{
|
||||
get => asset.devices;
|
||||
set => asset.devices = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionAsset.controlSchemes" />
|
||||
public ReadOnlyArray<InputControlScheme> controlSchemes => asset.controlSchemes;
|
||||
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionAsset.Contains(InputAction)" />
|
||||
public bool Contains(InputAction action)
|
||||
{
|
||||
return asset.Contains(action);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionAsset.GetEnumerator()" />
|
||||
public IEnumerator<InputAction> GetEnumerator()
|
||||
{
|
||||
return asset.GetEnumerator();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IEnumerable.GetEnumerator()" />
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return GetEnumerator();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionAsset.Enable()" />
|
||||
public void Enable()
|
||||
{
|
||||
asset.Enable();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionAsset.Disable()" />
|
||||
public void Disable()
|
||||
{
|
||||
asset.Disable();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionAsset.bindings" />
|
||||
public IEnumerable<InputBinding> bindings => asset.bindings;
|
||||
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionAsset.FindAction(string, bool)" />
|
||||
public InputAction FindAction(string actionNameOrId, bool throwIfNotFound = false)
|
||||
{
|
||||
return asset.FindAction(actionNameOrId, throwIfNotFound);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionAsset.FindBinding(InputBinding, out InputAction)" />
|
||||
public int FindBinding(InputBinding bindingMask, out InputAction action)
|
||||
{
|
||||
return asset.FindBinding(bindingMask, out action);
|
||||
}
|
||||
|
||||
// Gameplay
|
||||
private readonly InputActionMap m_Gameplay;
|
||||
private List<IGameplayActions> m_GameplayActionsCallbackInterfaces = new List<IGameplayActions>();
|
||||
private readonly InputAction m_Gameplay_Move;
|
||||
private readonly InputAction m_Gameplay_Aim;
|
||||
private readonly InputAction m_Gameplay_Fire;
|
||||
/// <summary>
|
||||
/// Provides access to input actions defined in input action map "Gameplay".
|
||||
/// </summary>
|
||||
public struct GameplayActions
|
||||
{
|
||||
private @ProjectMInput m_Wrapper;
|
||||
|
||||
/// <summary>
|
||||
/// Construct a new instance of the input action map wrapper class.
|
||||
/// </summary>
|
||||
public GameplayActions(@ProjectMInput wrapper) { m_Wrapper = wrapper; }
|
||||
/// <summary>
|
||||
/// Provides access to the underlying input action "Gameplay/Move".
|
||||
/// </summary>
|
||||
public InputAction @Move => m_Wrapper.m_Gameplay_Move;
|
||||
/// <summary>
|
||||
/// Provides access to the underlying input action "Gameplay/Aim".
|
||||
/// </summary>
|
||||
public InputAction @Aim => m_Wrapper.m_Gameplay_Aim;
|
||||
/// <summary>
|
||||
/// Provides access to the underlying input action "Gameplay/Fire".
|
||||
/// </summary>
|
||||
public InputAction @Fire => m_Wrapper.m_Gameplay_Fire;
|
||||
/// <summary>
|
||||
/// Provides access to the underlying input action map instance.
|
||||
/// </summary>
|
||||
public InputActionMap Get() { return m_Wrapper.m_Gameplay; }
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionMap.Enable()" />
|
||||
public void Enable() { Get().Enable(); }
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionMap.Disable()" />
|
||||
public void Disable() { Get().Disable(); }
|
||||
/// <inheritdoc cref="UnityEngine.InputSystem.InputActionMap.enabled" />
|
||||
public bool enabled => Get().enabled;
|
||||
/// <summary>
|
||||
/// Implicitly converts an <see ref="GameplayActions" /> to an <see ref="InputActionMap" /> instance.
|
||||
/// </summary>
|
||||
public static implicit operator InputActionMap(GameplayActions set) { return set.Get(); }
|
||||
/// <summary>
|
||||
/// Adds <see cref="InputAction.started"/>, <see cref="InputAction.performed"/> and <see cref="InputAction.canceled"/> callbacks provided via <param cref="instance" /> on all input actions contained in this map.
|
||||
/// </summary>
|
||||
/// <param name="instance">Callback instance.</param>
|
||||
/// <remarks>
|
||||
/// If <paramref name="instance" /> is <c>null</c> or <paramref name="instance"/> have already been added this method does nothing.
|
||||
/// </remarks>
|
||||
/// <seealso cref="GameplayActions" />
|
||||
public void AddCallbacks(IGameplayActions instance)
|
||||
{
|
||||
if (instance == null || m_Wrapper.m_GameplayActionsCallbackInterfaces.Contains(instance)) return;
|
||||
m_Wrapper.m_GameplayActionsCallbackInterfaces.Add(instance);
|
||||
@Move.started += instance.OnMove;
|
||||
@Move.performed += instance.OnMove;
|
||||
@Move.canceled += instance.OnMove;
|
||||
@Aim.started += instance.OnAim;
|
||||
@Aim.performed += instance.OnAim;
|
||||
@Aim.canceled += instance.OnAim;
|
||||
@Fire.started += instance.OnFire;
|
||||
@Fire.performed += instance.OnFire;
|
||||
@Fire.canceled += instance.OnFire;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes <see cref="InputAction.started"/>, <see cref="InputAction.performed"/> and <see cref="InputAction.canceled"/> callbacks provided via <param cref="instance" /> on all input actions contained in this map.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Calling this method when <paramref name="instance" /> have not previously been registered has no side-effects.
|
||||
/// </remarks>
|
||||
/// <seealso cref="GameplayActions" />
|
||||
private void UnregisterCallbacks(IGameplayActions instance)
|
||||
{
|
||||
@Move.started -= instance.OnMove;
|
||||
@Move.performed -= instance.OnMove;
|
||||
@Move.canceled -= instance.OnMove;
|
||||
@Aim.started -= instance.OnAim;
|
||||
@Aim.performed -= instance.OnAim;
|
||||
@Aim.canceled -= instance.OnAim;
|
||||
@Fire.started -= instance.OnFire;
|
||||
@Fire.performed -= instance.OnFire;
|
||||
@Fire.canceled -= instance.OnFire;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters <param cref="instance" /> and unregisters all input action callbacks via <see cref="GameplayActions.UnregisterCallbacks(IGameplayActions)" />.
|
||||
/// </summary>
|
||||
/// <seealso cref="GameplayActions.UnregisterCallbacks(IGameplayActions)" />
|
||||
public void RemoveCallbacks(IGameplayActions instance)
|
||||
{
|
||||
if (m_Wrapper.m_GameplayActionsCallbackInterfaces.Remove(instance))
|
||||
UnregisterCallbacks(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces all existing callback instances and previously registered input action callbacks associated with them with callbacks provided via <param cref="instance" />.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If <paramref name="instance" /> is <c>null</c>, calling this method will only unregister all existing callbacks but not register any new callbacks.
|
||||
/// </remarks>
|
||||
/// <seealso cref="GameplayActions.AddCallbacks(IGameplayActions)" />
|
||||
/// <seealso cref="GameplayActions.RemoveCallbacks(IGameplayActions)" />
|
||||
/// <seealso cref="GameplayActions.UnregisterCallbacks(IGameplayActions)" />
|
||||
public void SetCallbacks(IGameplayActions instance)
|
||||
{
|
||||
foreach (var item in m_Wrapper.m_GameplayActionsCallbackInterfaces)
|
||||
UnregisterCallbacks(item);
|
||||
m_Wrapper.m_GameplayActionsCallbackInterfaces.Clear();
|
||||
AddCallbacks(instance);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Provides a new <see cref="GameplayActions" /> instance referencing this action map.
|
||||
/// </summary>
|
||||
public GameplayActions @Gameplay => new GameplayActions(this);
|
||||
/// <summary>
|
||||
/// Interface to implement callback methods for all input action callbacks associated with input actions defined by "Gameplay" which allows adding and removing callbacks.
|
||||
/// </summary>
|
||||
/// <seealso cref="GameplayActions.AddCallbacks(IGameplayActions)" />
|
||||
/// <seealso cref="GameplayActions.RemoveCallbacks(IGameplayActions)" />
|
||||
public interface IGameplayActions
|
||||
{
|
||||
/// <summary>
|
||||
/// Method invoked when associated input action "Move" is either <see cref="UnityEngine.InputSystem.InputAction.started" />, <see cref="UnityEngine.InputSystem.InputAction.performed" /> or <see cref="UnityEngine.InputSystem.InputAction.canceled" />.
|
||||
/// </summary>
|
||||
/// <seealso cref="UnityEngine.InputSystem.InputAction.started" />
|
||||
/// <seealso cref="UnityEngine.InputSystem.InputAction.performed" />
|
||||
/// <seealso cref="UnityEngine.InputSystem.InputAction.canceled" />
|
||||
void OnMove(InputAction.CallbackContext context);
|
||||
/// <summary>
|
||||
/// Method invoked when associated input action "Aim" is either <see cref="UnityEngine.InputSystem.InputAction.started" />, <see cref="UnityEngine.InputSystem.InputAction.performed" /> or <see cref="UnityEngine.InputSystem.InputAction.canceled" />.
|
||||
/// </summary>
|
||||
/// <seealso cref="UnityEngine.InputSystem.InputAction.started" />
|
||||
/// <seealso cref="UnityEngine.InputSystem.InputAction.performed" />
|
||||
/// <seealso cref="UnityEngine.InputSystem.InputAction.canceled" />
|
||||
void OnAim(InputAction.CallbackContext context);
|
||||
/// <summary>
|
||||
/// Method invoked when associated input action "Fire" is either <see cref="UnityEngine.InputSystem.InputAction.started" />, <see cref="UnityEngine.InputSystem.InputAction.performed" /> or <see cref="UnityEngine.InputSystem.InputAction.canceled" />.
|
||||
/// </summary>
|
||||
/// <seealso cref="UnityEngine.InputSystem.InputAction.started" />
|
||||
/// <seealso cref="UnityEngine.InputSystem.InputAction.performed" />
|
||||
/// <seealso cref="UnityEngine.InputSystem.InputAction.canceled" />
|
||||
void OnFire(InputAction.CallbackContext context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d0226495dbc844da9ddfb554d46aa02
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d9e7bf5afc824f079f0a869c9948ad9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,98 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Prototype ARPG follow camera. Attach to the Main Camera (a plain MonoBehaviour): each LateUpdate
|
||||
/// it frames the local player at a fixed pitch/yaw and distance — the classic top-down/isometric ARPG
|
||||
/// framing (V Rising / Diablo / PoE2). Every lever is exposed so the feel can be dialled in live in
|
||||
/// Play Mode. The player's world position is published by <see cref="PrototypeCameraTargetSystem"/>
|
||||
/// (an ECS system that observes job dependencies safely); the MonoBehaviour never touches the
|
||||
/// EntityManager directly — doing so from LateUpdate raced the subscene async-load job and threw a
|
||||
/// job-safety exception at startup. Before a player spawns it frames <see cref="FallbackTarget"/>.
|
||||
/// <para>
|
||||
/// Presentation only — uses wall-clock <c>Time.deltaTime</c> for framerate-independent smoothing,
|
||||
/// which is correct here (not deterministic simulation). Default: mid 3/4 ~45°, perspective.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Camera))]
|
||||
[DisallowMultipleComponent]
|
||||
public class PrototypeCameraRig : MonoBehaviour
|
||||
{
|
||||
/// <summary>Local player world position, published each client tick by <see cref="PrototypeCameraTargetSystem"/>.</summary>
|
||||
public static float3 TargetWorldPos;
|
||||
|
||||
/// <summary>True while a locally-owned player exists to follow.</summary>
|
||||
public static bool HasTarget;
|
||||
|
||||
[Header("Angle (degrees)")]
|
||||
[Range(10f, 89f)] public float Pitch = 45f;
|
||||
[Range(-180f, 180f)] public float Yaw = 0f;
|
||||
|
||||
[Header("Framing")]
|
||||
[Min(1f)] public float Distance = 16f;
|
||||
[Tooltip("Raise the look-at point off the ground toward the character's centre of mass.")]
|
||||
public float TargetHeight = 1f;
|
||||
|
||||
[Header("Lens")]
|
||||
public bool Orthographic = false;
|
||||
[Range(20f, 90f)] public float FieldOfView = 55f;
|
||||
[Min(1f)] public float OrthoSize = 10f;
|
||||
|
||||
[Header("Follow")]
|
||||
[Tooltip("Higher = snappier follow. 0 = instant. Framerate-independent.")]
|
||||
[Min(0f)] public float FollowSharpness = 8f;
|
||||
[Tooltip("What to frame before a local player exists (edit mode / pre-spawn).")]
|
||||
public Vector3 FallbackTarget = new Vector3(3f, 0f, 4f);
|
||||
|
||||
Camera _cam;
|
||||
|
||||
void Awake() => _cam = GetComponent<Camera>();
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
if (_cam == null) _cam = GetComponent<Camera>();
|
||||
|
||||
_cam.orthographic = Orthographic;
|
||||
_cam.fieldOfView = FieldOfView;
|
||||
_cam.orthographicSize = OrthoSize;
|
||||
|
||||
Vector3 target = HasTarget ? (Vector3)TargetWorldPos : FallbackTarget;
|
||||
target.y += TargetHeight;
|
||||
|
||||
var rot = Quaternion.Euler(Pitch, Yaw, 0f);
|
||||
Vector3 desired = target - (rot * Vector3.forward) * Distance;
|
||||
|
||||
float k = FollowSharpness <= 0f ? 1f : 1f - Mathf.Exp(-FollowSharpness * Time.deltaTime);
|
||||
transform.SetPositionAndRotation(Vector3.Lerp(transform.position, desired, k), rot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the locally-owned player's world position to <see cref="PrototypeCameraRig"/> statics
|
||||
/// each client tick. Lives in ECS (not the camera MonoBehaviour) so reading <see cref="LocalTransform"/>
|
||||
/// respects job dependencies — avoiding the subscene-load job-safety exception that direct
|
||||
/// EntityManager access from LateUpdate caused.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
public partial class PrototypeCameraTargetSystem : SystemBase
|
||||
{
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
bool found = false;
|
||||
foreach (var transform in SystemAPI.Query<RefRO<LocalTransform>>()
|
||||
.WithAll<GhostOwnerIsLocal, PlayerTag>())
|
||||
{
|
||||
PrototypeCameraRig.TargetWorldPos = transform.ValueRO.Position;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
PrototypeCameraRig.HasTarget = found;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e5890693b64a429789bf3edfae0a6ff
|
||||
@@ -4,6 +4,7 @@
|
||||
"references": [
|
||||
"ProjectM.Simulation",
|
||||
"Unity.Entities",
|
||||
"Unity.Transforms",
|
||||
"Unity.Collections",
|
||||
"Unity.Mathematics",
|
||||
"Unity.Burst",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a8dcd28017b9e4abead6e2dc32ef9383
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,68 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative damage application. Drains each damageable entity's
|
||||
/// <see cref="DamageEvent"/> buffer (appended by <see cref="ProjectileDamageSystem"/> earlier
|
||||
/// this tick), subtracts the summed amount from <see cref="Health"/>, then clears the buffer so
|
||||
/// each hit is applied exactly once. Entities that carry character stats (players) clamp to their
|
||||
/// data-driven <see cref="EffectiveCharacterStats.MaxHealth"/> ceiling; others (training dummies)
|
||||
/// clamp at zero. A dead <see cref="TrainingDummyTag"/> is destroyed; player death is deferred.
|
||||
/// Health.Current is a <c>[GhostField]</c>, so the new value replicates to clients for display.
|
||||
///
|
||||
/// Runs server-only (<see cref="WorldSystemFilterFlags.ServerSimulation"/>) inside the prediction
|
||||
/// group so it shares tick timing with movement/damage, where it executes once per tick. The
|
||||
/// single structural change (destroying a dead dummy) is batched through a frame-allocator
|
||||
/// <see cref="EntityCommandBuffer"/> that is played back immediately to the entity manager — so a
|
||||
/// plain-world EditMode test needs no separate ECB system.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(ProjectileDamageSystem))]
|
||||
[BurstCompile]
|
||||
public partial struct HealthApplyDamageSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (health, dmg, entity) in
|
||||
SystemAPI.Query<RefRW<Health>, DynamicBuffer<DamageEvent>>()
|
||||
.WithEntityAccess())
|
||||
{
|
||||
if (dmg.Length == 0)
|
||||
continue;
|
||||
|
||||
float total = 0f;
|
||||
for (int i = 0; i < dmg.Length; i++)
|
||||
total += dmg[i].Amount;
|
||||
dmg.Clear();
|
||||
|
||||
float newHp = health.ValueRO.Current - total;
|
||||
|
||||
// Effective max health (base + modifiers) is the runtime ceiling for entities that carry
|
||||
// character stats (players); others just clamp at zero. No auto-heal on a max increase.
|
||||
if (SystemAPI.HasComponent<EffectiveCharacterStats>(entity))
|
||||
newHp = math.clamp(newHp, 0f, SystemAPI.GetComponent<EffectiveCharacterStats>(entity).MaxHealth);
|
||||
else
|
||||
newHp = math.max(0f, newHp);
|
||||
|
||||
health.ValueRW.Current = newHp;
|
||||
|
||||
// Server-authoritative death: training dummies despawn; player death is deferred (clamp only).
|
||||
if (health.ValueRO.Current <= 0f && SystemAPI.HasComponent<TrainingDummyTag>(entity))
|
||||
ecb.DestroyEntity(entity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7c1729291515a4966b83ef050554a772
|
||||
@@ -0,0 +1,145 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative projectile resolution: applies hits to damageable entities and expires
|
||||
/// projectiles past their range. Runs in the server world only
|
||||
/// (<see cref="WorldSystemFilterFlags.ServerSimulation"/>) inside the
|
||||
/// <see cref="PredictedSimulationSystemGroup"/>, ordered <see cref="ProjectileMoveSystem"/> so the
|
||||
/// projectile's <see cref="LocalTransform"/> is the post-move position for this tick.
|
||||
///
|
||||
/// Hit detection is a <b>swept</b> planar (XZ) test: rather than checking the projectile's point
|
||||
/// position (which tunnels straight through a target when the per-tick step exceeds the target's
|
||||
/// radius — e.g. a fast projectile, or any projectile while the server is tick-batching under load),
|
||||
/// it reconstructs the segment the projectile traversed this tick
|
||||
/// (<c>[curPos - dir*speed*dt, curPos]</c>) and tests each target's hit radius against the closest
|
||||
/// point on that segment. The target hit earliest along the path (smallest segment parameter) wins.
|
||||
/// A target whose <see cref="GhostOwner"/> matches the projectile's owner is skipped (no self-hits);
|
||||
/// dummies carry no <see cref="GhostOwner"/> and are therefore always valid targets.
|
||||
///
|
||||
/// On a hit the system appends a <see cref="DamageEvent"/> to the target (consumed by
|
||||
/// <c>HealthApplyDamageSystem</c>) and destroys the projectile. Deferring damage to a buffer lets a
|
||||
/// single tick stack hits from multiple projectiles. All structural changes go through an
|
||||
/// <see cref="EntityCommandBuffer"/> that plays back immediately to the
|
||||
/// <see cref="EntityManager"/> (Temp allocator) — keeping this server-only, once-per-tick system
|
||||
/// self-contained and plain-world testable without a separate ECB system.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(ProjectileMoveSystem))]
|
||||
public partial struct ProjectileDamageSystem : ISystem
|
||||
{
|
||||
/// <summary>Lookup used to read a target's owner so a projectile never hits its own caster.</summary>
|
||||
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
|
||||
|
||||
/// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary>
|
||||
const float k_ProjectileRadius = 0.2f;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
|
||||
|
||||
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
|
||||
state.RequireForUpdate<Projectile>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
m_GhostOwnerLookup.Update(ref state);
|
||||
|
||||
float dt = SystemAPI.Time.DeltaTime;
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
// Snapshot all damageable targets once for this tick. Stable iteration order (query order).
|
||||
var targetEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var targetPositions = new NativeList<float3>(Allocator.Temp);
|
||||
var targetRadii = new NativeList<float>(Allocator.Temp);
|
||||
|
||||
foreach (var (xform, hitRadius, targetEntity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>>()
|
||||
.WithAll<Health>()
|
||||
.WithEntityAccess())
|
||||
{
|
||||
targetEntities.Add(targetEntity);
|
||||
targetPositions.Add(xform.ValueRO.Position);
|
||||
targetRadii.Add(hitRadius.ValueRO.Value);
|
||||
}
|
||||
|
||||
foreach (var (xform, proj, owner, projectileEntity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Projectile>, RefRO<GhostOwner>>()
|
||||
.WithEntityAccess())
|
||||
{
|
||||
int projOwnerId = owner.ValueRO.NetworkId;
|
||||
|
||||
// This tick's planar travel segment: [segStart -> segEnd]. Sweeping the segment (rather
|
||||
// than testing only segEnd) is what prevents fast projectiles from tunnelling targets.
|
||||
float3 cur = xform.ValueRO.Position;
|
||||
float2 segEnd = new float2(cur.x, cur.z);
|
||||
float2 segStart = segEnd - proj.ValueRO.Direction * (proj.ValueRO.Speed * dt);
|
||||
float2 seg = segEnd - segStart;
|
||||
float segLenSq = math.lengthsq(seg);
|
||||
|
||||
int bestIdx = -1;
|
||||
float bestT = float.MaxValue;
|
||||
for (int i = 0; i < targetEntities.Length; i++)
|
||||
{
|
||||
var target = targetEntities[i];
|
||||
|
||||
// Skip the caster: a target whose GhostOwner matches the projectile owner is the
|
||||
// shooter (or another ghost they own). Dummies have no GhostOwner, so never skipped.
|
||||
if (m_GhostOwnerLookup.HasComponent(target) &&
|
||||
m_GhostOwnerLookup[target].NetworkId == projOwnerId)
|
||||
continue;
|
||||
|
||||
float2 tp = new float2(targetPositions[i].x, targetPositions[i].z);
|
||||
|
||||
// Closest point on the travel segment to the target centre.
|
||||
float t = segLenSq > 1e-8f
|
||||
? math.saturate(math.dot(tp - segStart, seg) / segLenSq)
|
||||
: 0f;
|
||||
float2 closest = segStart + t * seg;
|
||||
|
||||
float hitDist = targetRadii[i] + k_ProjectileRadius;
|
||||
if (math.distancesq(tp, closest) <= hitDist * hitDist && t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx >= 0)
|
||||
{
|
||||
// Earliest target along the travel path: deal damage and consume the projectile.
|
||||
ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent
|
||||
{
|
||||
Amount = proj.ValueRO.Damage,
|
||||
SourceNetworkId = projOwnerId,
|
||||
});
|
||||
ecb.DestroyEntity(projectileEntity);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nothing hit this tick: expire the projectile once it has travelled its full range.
|
||||
if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range)
|
||||
ecb.DestroyEntity(projectileEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
|
||||
ecb.Dispose();
|
||||
targetEntities.Dispose();
|
||||
targetPositions.Dispose();
|
||||
targetRadii.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f19fa77609fa04c2ca4e293f19c052a4
|
||||
@@ -0,0 +1,52 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-only, one-shot dummy spawner. On its first update it reads the baked
|
||||
/// <see cref="TrainingDummySpawner"/> singleton and instantiates <c>Count</c> training-dummy
|
||||
/// ghosts in a row, spaced <c>Spacing</c> world-units apart along +X starting at <c>Origin</c>.
|
||||
/// It then destroys the spawner singleton entity so <c>RequireForUpdate<TrainingDummySpawner></c>
|
||||
/// is no longer satisfied and the system stops running (idempotent: dummies are spawned exactly once).
|
||||
/// Runs in the default <see cref="SimulationSystemGroup"/> (NOT the prediction loop) since spawning is
|
||||
/// a non-predicted, server-authoritative event; the dummies replicate to clients as interpolated ghosts.
|
||||
/// All structural changes are batched through an <see cref="EntityCommandBuffer"/>.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct TrainingDummySpawnSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<TrainingDummySpawner>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
// Grab both the singleton entity (to destroy when done) and its baked config.
|
||||
var spawnerEntity = SystemAPI.GetSingletonEntity<TrainingDummySpawner>();
|
||||
var spawner = SystemAPI.GetComponent<TrainingDummySpawner>(spawnerEntity);
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
for (int i = 0; i < spawner.Count; i++)
|
||||
{
|
||||
var dummy = ecb.Instantiate(spawner.Prefab);
|
||||
var position = spawner.Origin + new float3(i * spawner.Spacing, 0f, 0f);
|
||||
ecb.SetComponent(dummy, LocalTransform.FromPosition(position));
|
||||
}
|
||||
|
||||
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
|
||||
ecb.DestroyEntity(spawnerEntity);
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7673acbc74f7a4bddbca0679122511ea
|
||||
@@ -0,0 +1,52 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-only, one-shot upgrade-pickup spawner (mirrors TrainingDummySpawnSystem). On its first
|
||||
/// update it reads the baked <see cref="UpgradePickupSpawner"/> singleton and instantiates
|
||||
/// <c>Count</c> pickup ghosts in a row, spaced <c>Spacing</c> world-units apart along +X starting at
|
||||
/// <c>Origin</c>, then destroys the singleton so the system idles (spawned exactly once). Runs in the
|
||||
/// default <see cref="SimulationSystemGroup"/> (NOT the prediction loop); pickups replicate to clients
|
||||
/// as interpolated ghosts. Structural changes are batched through an <see cref="EntityCommandBuffer"/>.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct UpgradePickupSpawnSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<UpgradePickupSpawner>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var spawnerEntity = SystemAPI.GetSingletonEntity<UpgradePickupSpawner>();
|
||||
var spawner = SystemAPI.GetComponent<UpgradePickupSpawner>(spawnerEntity);
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
if (spawner.Prefab != Entity.Null)
|
||||
{
|
||||
for (int i = 0; i < spawner.Count; i++)
|
||||
{
|
||||
var pickup = ecb.Instantiate(spawner.Prefab);
|
||||
var position = spawner.Origin + new float3(i * spawner.Spacing, 0f, 0f);
|
||||
ecb.SetComponent(pickup, LocalTransform.FromPosition(position));
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
|
||||
ecb.DestroyEntity(spawnerEntity);
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 602544c80ca5649258a9d6ca9ad74f79
|
||||
@@ -0,0 +1,78 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative upgrade pickup grant. When a player overlaps an <see cref="UpgradePickup"/>
|
||||
/// (planar XZ distance within the pickup's <see cref="HitRadius"/>), appends the pickup's modifier to
|
||||
/// the player's replicated <see cref="StatModifier"/> buffer (which replicates to the predicting
|
||||
/// owner, so StatRecomputeSystem folds identical effective stats on both worlds) and destroys the
|
||||
/// pickup. Runs in the default <see cref="SimulationSystemGroup"/> (NOT the prediction loop) since the
|
||||
/// grant is a non-predicted server event. The buffer append + pickup destroy are batched through an
|
||||
/// <see cref="EntityCommandBuffer"/> played back immediately — so a plain-world EditMode test needs no
|
||||
/// separate ECB system.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct UpgradePickupSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<UpgradePickup>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
// Snapshot modifiable players (carrying the modifier buffer + a transform) once this tick.
|
||||
var playerEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var playerPositions = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (xform, e) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>>()
|
||||
.WithAll<PlayerTag, StatModifier>()
|
||||
.WithEntityAccess())
|
||||
{
|
||||
playerEntities.Add(e);
|
||||
playerPositions.Add(xform.ValueRO.Position);
|
||||
}
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (xform, radius, pickup, pickupEntity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<UpgradePickup>>()
|
||||
.WithEntityAccess())
|
||||
{
|
||||
float2 pp = new float2(xform.ValueRO.Position.x, xform.ValueRO.Position.z);
|
||||
float r = radius.ValueRO.Value;
|
||||
|
||||
for (int i = 0; i < playerEntities.Length; i++)
|
||||
{
|
||||
float2 cp = new float2(playerPositions[i].x, playerPositions[i].z);
|
||||
if (math.distancesq(pp, cp) > r * r)
|
||||
continue;
|
||||
|
||||
ecb.AppendToBuffer(playerEntities[i], new StatModifier
|
||||
{
|
||||
Target = pickup.ValueRO.Target,
|
||||
Op = pickup.ValueRO.Op,
|
||||
Value = pickup.ValueRO.Value,
|
||||
SourceId = pickup.ValueRO.SourceId,
|
||||
});
|
||||
ecb.DestroyEntity(pickupEntity);
|
||||
break; // granted to the first overlapping player, then despawns
|
||||
}
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
playerEntities.Dispose();
|
||||
playerPositions.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c3d9ef25fbc464e52aa342b531d6f35e
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 94ea01b14c1384c9f96d8f7bd6ac1a14
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,89 @@
|
||||
#if UNITY_EDITOR
|
||||
using System.Collections.Generic;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Editor-only debug hook (mirrors ProjectM.Client.DebugInputInjectionSystem's static-poke pattern)
|
||||
/// for driving the server-authoritative modifier stack from MCP execute_code. Because modifiers are
|
||||
/// server-authoritative, a client-side append would be stomped by the next snapshot, so this runs in
|
||||
/// the SERVER world: the change flows back through the snapshot and is prediction-correct on the
|
||||
/// client. In-editor single-process only (client + server worlds in one process). Poke from execute_code:
|
||||
/// DebugModifierInjectionSystem.AddModifier((byte)StatTarget.Damage, (byte)ModOp.Flat, 50f);
|
||||
/// DebugModifierInjectionSystem.AddModifier((byte)StatTarget.MoveSpeed, (byte)ModOp.PercentAdd, 0.5f);
|
||||
/// DebugModifierInjectionSystem.CycleAbility(); // Primary -> FastLight -> SlowHeavy -> Primary
|
||||
/// DebugModifierInjectionSystem.ClearModifiers();
|
||||
/// All applied to the first player on the next server tick.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial class DebugModifierInjectionSystem : SystemBase
|
||||
{
|
||||
struct PendingModifier { public byte Target; public byte Op; public float Value; }
|
||||
|
||||
static readonly List<PendingModifier> s_Pending = new List<PendingModifier>();
|
||||
static bool s_Clear;
|
||||
static bool s_Cycle;
|
||||
|
||||
/// <summary>Queue a modifier to append to the first player on the next server tick.</summary>
|
||||
public static void AddModifier(byte target, byte op, float value)
|
||||
{
|
||||
s_Pending.Add(new PendingModifier { Target = target, Op = op, Value = value });
|
||||
}
|
||||
|
||||
/// <summary>Clear the first player's whole modifier stack on the next server tick.</summary>
|
||||
public static void ClearModifiers() => s_Clear = true;
|
||||
|
||||
/// <summary>Cycle the first player's primary ability id on the next server tick.</summary>
|
||||
public static void CycleAbility() => s_Cycle = true;
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (s_Pending.Count == 0 && !s_Clear && !s_Cycle)
|
||||
return;
|
||||
|
||||
Entity player = Entity.Null;
|
||||
foreach (var (abilityRef, e) in
|
||||
SystemAPI.Query<RefRO<AbilityRef>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
|
||||
{
|
||||
player = e;
|
||||
break;
|
||||
}
|
||||
if (player == Entity.Null)
|
||||
return;
|
||||
|
||||
if (s_Clear)
|
||||
{
|
||||
EntityManager.GetBuffer<StatModifier>(player).Clear();
|
||||
s_Clear = false;
|
||||
}
|
||||
|
||||
if (s_Pending.Count > 0)
|
||||
{
|
||||
var buffer = EntityManager.GetBuffer<StatModifier>(player);
|
||||
for (int i = 0; i < s_Pending.Count; i++)
|
||||
{
|
||||
var m = s_Pending[i];
|
||||
buffer.Add(new StatModifier { Target = m.Target, Op = m.Op, Value = m.Value, SourceId = 0u });
|
||||
}
|
||||
s_Pending.Clear();
|
||||
}
|
||||
|
||||
if (s_Cycle)
|
||||
{
|
||||
var abilityRef = EntityManager.GetComponentData<AbilityRef>(player);
|
||||
abilityRef.Id = abilityRef.Id switch
|
||||
{
|
||||
(byte)AbilityId.Primary => (byte)AbilityId.FastLight,
|
||||
(byte)AbilityId.FastLight => (byte)AbilityId.SlowHeavy,
|
||||
_ => (byte)AbilityId.Primary,
|
||||
};
|
||||
EntityManager.SetComponentData(player, abilityRef);
|
||||
s_Cycle = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1f8906df777734bbcb3f21100d6e664e
|
||||
@@ -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