Initial Combat Implementation

This commit is contained in:
Luis Gonzalez
2026-05-31 21:35:12 -07:00
parent 7fa77ce821
commit 1f647dd5e1
166 changed files with 93337 additions and 91 deletions
@@ -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&lt;T&gt;() 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 -&gt; Move, right-stick -&gt; 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&lt;PlayerInput&gt;</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&lt;PlayerInput&gt;</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&lt;Vector2&gt;()}");
/// }
///
/// // Invoked when "Attack" action is either started, performed or canceled.
/// public void OnAttack(InputAction.CallbackContext context)
/// {
/// Debug.Log($"OnAttack: {context.ReadValue&lt;float&gt;()}");
/// }
///
/// #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",