process TODO.md step2
This commit is contained in:
parent
a0a8c0a89d
commit
4a152e765b
|
|
@ -4,6 +4,54 @@ using Network.NetworkApplication;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Vector3 = UnityEngine.Vector3;
|
using Vector3 = UnityEngine.Vector3;
|
||||||
|
|
||||||
|
public static class ClientGameplayInputFlow
|
||||||
|
{
|
||||||
|
public static bool HasPlanarInput(Vector3 input)
|
||||||
|
{
|
||||||
|
return new Vector2(input.x, input.z).sqrMagnitude > 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryCreateMoveInput(string playerId, long tick, Vector3 input, bool stopMessagePending, out MoveInput message)
|
||||||
|
{
|
||||||
|
if (!HasPlanarInput(input) && !stopMessagePending)
|
||||||
|
{
|
||||||
|
message = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
message = new MoveInput
|
||||||
|
{
|
||||||
|
PlayerId = playerId,
|
||||||
|
Tick = tick,
|
||||||
|
MoveX = input.x,
|
||||||
|
MoveY = input.z
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ShootInput CreateShootInput(string playerId, long tick, Vector3 aimDirection, string targetId = "")
|
||||||
|
{
|
||||||
|
var planarDirection = new Vector3(aimDirection.x, 0f, aimDirection.z);
|
||||||
|
if (planarDirection.sqrMagnitude <= 0f)
|
||||||
|
{
|
||||||
|
planarDirection = Vector3.forward;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
planarDirection.Normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ShootInput
|
||||||
|
{
|
||||||
|
PlayerId = playerId,
|
||||||
|
Tick = tick,
|
||||||
|
DirX = planarDirection.x,
|
||||||
|
DirY = planarDirection.z,
|
||||||
|
TargetId = targetId ?? string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class MovementComponent : MonoBehaviour
|
public class MovementComponent : MonoBehaviour
|
||||||
{
|
{
|
||||||
[SerializeField] private float _sendInterval = 0.05f;
|
[SerializeField] private float _sendInterval = 0.05f;
|
||||||
|
|
@ -26,7 +74,10 @@ public class MovementComponent : MonoBehaviour
|
||||||
private Vector3 _currentPos;
|
private Vector3 _currentPos;
|
||||||
private float _lerpTime;
|
private float _lerpTime;
|
||||||
[SerializeField] private float _lerpRate = 0.1f;
|
[SerializeField] private float _lerpRate = 0.1f;
|
||||||
private MoveInput _cachedInput;
|
private Vector3 _cachedMoveInput;
|
||||||
|
private Vector3 _lastAimDirection = Vector3.forward;
|
||||||
|
private bool _wasMovingLastFrame;
|
||||||
|
private bool _stopMessagePending;
|
||||||
|
|
||||||
public void Init(bool isControlled, Player master, int speed = 0, long serverTick = 0)
|
public void Init(bool isControlled, Player master, int speed = 0, long serverTick = 0)
|
||||||
{
|
{
|
||||||
|
|
@ -44,13 +95,33 @@ public class MovementComponent : MonoBehaviour
|
||||||
{
|
{
|
||||||
if (_isControlled)
|
if (_isControlled)
|
||||||
{
|
{
|
||||||
_cachedInput = CaptureInput();
|
_cachedMoveInput = CaptureMovement();
|
||||||
|
var hasMovement = ClientGameplayInputFlow.HasPlanarInput(_cachedMoveInput);
|
||||||
|
if (hasMovement)
|
||||||
|
{
|
||||||
|
_lastAimDirection = _cachedMoveInput;
|
||||||
|
_stopMessagePending = false;
|
||||||
|
}
|
||||||
|
else if (_wasMovingLastFrame)
|
||||||
|
{
|
||||||
|
_stopMessagePending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wasMovingLastFrame = hasMovement;
|
||||||
|
|
||||||
|
var shootInput = CaptureShootInput();
|
||||||
|
if (shootInput != null)
|
||||||
|
{
|
||||||
|
NetworkManager.Instance.SendShootInput(shootInput);
|
||||||
|
}
|
||||||
|
|
||||||
if (Time.time - _lastSendTime > _sendInterval)
|
if (Time.time - _lastSendTime > _sendInterval)
|
||||||
{
|
{
|
||||||
if (_cachedInput != null)
|
if (ClientGameplayInputFlow.TryCreateMoveInput(_master.PlayerId, Tick, _cachedMoveInput, _stopMessagePending, out var moveInput))
|
||||||
{
|
{
|
||||||
NetworkManager.Instance.SendMoveInput(_cachedInput);
|
NetworkManager.Instance.SendMoveInput(moveInput);
|
||||||
|
_predictionBuffer.Record(moveInput);
|
||||||
|
_stopMessagePending = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastSendTime = Time.time;
|
_lastSendTime = Time.time;
|
||||||
|
|
@ -72,11 +143,7 @@ public class MovementComponent : MonoBehaviour
|
||||||
_hasServerState = false;
|
_hasServerState = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Simulate(_cachedInput);
|
Simulate(_cachedMoveInput);
|
||||||
if (_cachedInput != null)
|
|
||||||
{
|
|
||||||
_predictionBuffer.Record(_cachedInput);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -98,27 +165,36 @@ public class MovementComponent : MonoBehaviour
|
||||||
ReplayPendingInputs(replayInputs);
|
ReplayPendingInputs(replayInputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private MoveInput CaptureInput()
|
private Vector3 CaptureMovement()
|
||||||
{
|
{
|
||||||
var input = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical"));
|
return new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
|
||||||
if (input == Vector3.zero)
|
}
|
||||||
|
|
||||||
|
private ShootInput CaptureShootInput()
|
||||||
|
{
|
||||||
|
if (!Input.GetMouseButtonDown(0))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new MoveInput
|
return ClientGameplayInputFlow.CreateShootInput(_master.PlayerId, Tick, ResolveAimDirection());
|
||||||
{
|
|
||||||
PlayerId = _master.PlayerId,
|
|
||||||
Tick = Tick,
|
|
||||||
MoveX = input.x,
|
|
||||||
MoveY = input.z
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Simulate(MoveInput input)
|
private Vector3 ResolveAimDirection()
|
||||||
{
|
{
|
||||||
var dir = input == null ? Vector3.zero : new Vector3(input.MoveX, 0f, input.MoveY);
|
if (ClientGameplayInputFlow.HasPlanarInput(_lastAimDirection))
|
||||||
_rigid.velocity = _speed * dir;
|
{
|
||||||
|
return _lastAimDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
var forward = _master != null ? _master.transform.forward : transform.forward;
|
||||||
|
var planarForward = new Vector3(forward.x, 0f, forward.z);
|
||||||
|
return ClientGameplayInputFlow.HasPlanarInput(planarForward) ? planarForward : Vector3.forward;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Simulate(Vector3 input)
|
||||||
|
{
|
||||||
|
_rigid.velocity = _speed * input;
|
||||||
if (_isControlled)
|
if (_isControlled)
|
||||||
{
|
{
|
||||||
MainUI.Instance.OnClientPosChanged(_rigid.position);
|
MainUI.Instance.OnClientPosChanged(_rigid.position);
|
||||||
|
|
@ -182,4 +258,4 @@ public class MovementComponent : MonoBehaviour
|
||||||
MainUI.Instance.OnClientPosChanged(_rigid.position);
|
MainUI.Instance.OnClientPosChanged(_rigid.position);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "NetworkFramework",
|
||||||
|
"rootNamespace": "",
|
||||||
|
"references": [
|
||||||
|
"GUID:d972d56d6b084684b5b0666f4856da75"
|
||||||
|
],
|
||||||
|
"includePlatforms": [],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"precompiledReferences": [],
|
||||||
|
"autoReferenced": true,
|
||||||
|
"defineConstraints": [],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 25ab7ef51eeef1e4ea6e24413cbabdff
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -13,16 +13,17 @@ public class NetworkManager : MonoBehaviour
|
||||||
private const int DefaultReliablePort = 8080;
|
private const int DefaultReliablePort = 8080;
|
||||||
private const int DefaultSyncPort = 8081;
|
private const int DefaultSyncPort = 8081;
|
||||||
|
|
||||||
public static NetworkManager Instance;
|
|
||||||
private SharedNetworkRuntime _networkRuntime;
|
|
||||||
private IPEndPoint _serverPoint;
|
|
||||||
private uint _sequence = 0;
|
|
||||||
private Task _networkDrainTask = Task.CompletedTask;
|
|
||||||
[SerializeField] private GameObject _wrongWindow;
|
[SerializeField] private GameObject _wrongWindow;
|
||||||
[SerializeField] private bool _enableNetworkDiagnosticsOverlay = true;
|
[SerializeField] private bool _enableNetworkDiagnosticsOverlay = true;
|
||||||
[SerializeField] private string _serverIp = DefaultServerIp;
|
[SerializeField] private string _serverIp = DefaultServerIp;
|
||||||
[SerializeField] private int _reliablePort = DefaultReliablePort;
|
[SerializeField] private int _reliablePort = DefaultReliablePort;
|
||||||
[SerializeField] private int _syncPort = DefaultSyncPort;
|
[SerializeField] private int _syncPort = DefaultSyncPort;
|
||||||
|
|
||||||
|
public static NetworkManager Instance;
|
||||||
|
private SharedNetworkRuntime _networkRuntime;
|
||||||
|
private IPEndPoint _serverPoint;
|
||||||
|
private uint _sequence = 0;
|
||||||
|
private Task _networkDrainTask = Task.CompletedTask;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
|
|
@ -197,24 +198,23 @@ public class NetworkManager : MonoBehaviour
|
||||||
Debug.Log($"[NetworkManager] Session {lifecycleEvent.PreviousState} -> {lifecycleEvent.CurrentState} ({lifecycleEvent.Kind}) {lifecycleEvent.Reason}");
|
Debug.Log($"[NetworkManager] Session {lifecycleEvent.PreviousState} -> {lifecycleEvent.CurrentState} ({lifecycleEvent.Kind}) {lifecycleEvent.Reason}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SendMoveInput(string playerId, Vector3 input)
|
|
||||||
{
|
|
||||||
var message = new MoveInput
|
|
||||||
{
|
|
||||||
PlayerId = playerId,
|
|
||||||
MoveX = input.x,
|
|
||||||
MoveY = input.z
|
|
||||||
};
|
|
||||||
_networkRuntime.MessageManager.SendMessage(message, MessageType.MoveInput);
|
|
||||||
Debug.Log($"PlayerMoveSeq: {_sequence++}");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SendMoveInput(MoveInput message)
|
public void SendMoveInput(MoveInput message)
|
||||||
{
|
{
|
||||||
_networkRuntime.MessageManager.SendMessage(message, MessageType.MoveInput);
|
_networkRuntime.MessageManager.SendMessage(message, MessageType.MoveInput);
|
||||||
Debug.Log($"PlayerMoveSeq: {_sequence++}");
|
Debug.Log($"PlayerMoveSeq: {_sequence++}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SendShootInput(string playerId, Vector3 direction, long tick = 0, string targetId = "")
|
||||||
|
{
|
||||||
|
SendShootInput(ClientGameplayInputFlow.CreateShootInput(playerId, tick, direction, targetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SendShootInput(ShootInput message)
|
||||||
|
{
|
||||||
|
_networkRuntime.MessageManager.SendMessage(message, MessageType.ShootInput);
|
||||||
|
Debug.Log($"PlayerShootSeq: {_sequence++}");
|
||||||
|
}
|
||||||
|
|
||||||
public void SendLoginRequest(string playerId, int speed)
|
public void SendLoginRequest(string playerId, int speed)
|
||||||
{
|
{
|
||||||
var request = new LoginRequest()
|
var request = new LoginRequest()
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,38 @@ namespace Tests.EditMode.Network
|
||||||
Assert.That(parsed.MoveY, Is.EqualTo(-1));
|
Assert.That(parsed.MoveY, Is.EqualTo(-1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void SendMessage_ZeroVectorMoveInput_UsesSyncLanePolicy()
|
||||||
|
{
|
||||||
|
var reliableTransport = new FakeTransport();
|
||||||
|
var syncTransport = new FakeTransport();
|
||||||
|
var manager = new MessageManager(
|
||||||
|
reliableTransport,
|
||||||
|
new MainThreadNetworkDispatcher(),
|
||||||
|
new DefaultMessageDeliveryPolicyResolver(),
|
||||||
|
syncTransport);
|
||||||
|
var message = new MoveInput
|
||||||
|
{
|
||||||
|
PlayerId = "player-1",
|
||||||
|
Tick = 13,
|
||||||
|
MoveX = 0f,
|
||||||
|
MoveY = 0f
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.SendMessage(message, MessageType.MoveInput);
|
||||||
|
|
||||||
|
Assert.That(reliableTransport.SendCallCount, Is.EqualTo(0));
|
||||||
|
Assert.That(syncTransport.SendCallCount, Is.EqualTo(1));
|
||||||
|
|
||||||
|
var envelope = Envelope.Parser.ParseFrom(syncTransport.LastSentData);
|
||||||
|
var parsed = MoveInput.Parser.ParseFrom(envelope.Payload);
|
||||||
|
Assert.That(envelope.Type, Is.EqualTo((int)MessageType.MoveInput));
|
||||||
|
Assert.That(parsed.PlayerId, Is.EqualTo("player-1"));
|
||||||
|
Assert.That(parsed.Tick, Is.EqualTo(13));
|
||||||
|
Assert.That(parsed.MoveX, Is.EqualTo(0f));
|
||||||
|
Assert.That(parsed.MoveY, Is.EqualTo(0f));
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void SendMessage_ShootInput_UsesReliableLanePolicy()
|
public void SendMessage_ShootInput_UsesReliableLanePolicy()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
"references": [
|
"references": [
|
||||||
"Network.Runtime",
|
"Network.Runtime",
|
||||||
"UnityEngine.TestRunner",
|
"UnityEngine.TestRunner",
|
||||||
"UnityEditor.TestRunner"
|
"UnityEditor.TestRunner",
|
||||||
|
"NetworkFramework"
|
||||||
],
|
],
|
||||||
"includePlatforms": [
|
"includePlatforms": [
|
||||||
"Editor"
|
"Editor"
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,54 @@ using Network.NetworkApplication;
|
||||||
using Network.NetworkHost;
|
using Network.NetworkHost;
|
||||||
using Network.NetworkTransport;
|
using Network.NetworkTransport;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
using UnityEngine;
|
||||||
|
using Vector3 = UnityEngine.Vector3;
|
||||||
|
|
||||||
namespace Tests.EditMode.Network
|
namespace Tests.EditMode.Network
|
||||||
{
|
{
|
||||||
public class SyncStrategyTests
|
public class SyncStrategyTests
|
||||||
{
|
{
|
||||||
|
[Test]
|
||||||
|
public void ClientGameplayInputFlow_StopTransition_EmitsSingleZeroVectorMoveInput()
|
||||||
|
{
|
||||||
|
var released = ClientGameplayInputFlow.TryCreateMoveInput(
|
||||||
|
"player-1",
|
||||||
|
8,
|
||||||
|
Vector3.zero,
|
||||||
|
true,
|
||||||
|
out var stopInput);
|
||||||
|
var continuedIdle = ClientGameplayInputFlow.TryCreateMoveInput(
|
||||||
|
"player-1",
|
||||||
|
9,
|
||||||
|
Vector3.zero,
|
||||||
|
false,
|
||||||
|
out var idleInput);
|
||||||
|
|
||||||
|
Assert.That(released, Is.True);
|
||||||
|
Assert.That(stopInput, Is.Not.Null);
|
||||||
|
Assert.That(stopInput.PlayerId, Is.EqualTo("player-1"));
|
||||||
|
Assert.That(stopInput.Tick, Is.EqualTo(8));
|
||||||
|
Assert.That(stopInput.MoveX, Is.EqualTo(0f));
|
||||||
|
Assert.That(stopInput.MoveY, Is.EqualTo(0f));
|
||||||
|
Assert.That(continuedIdle, Is.False);
|
||||||
|
Assert.That(idleInput, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ClientGameplayInputFlow_CreateShootInput_UsesSplitShootMessageFields()
|
||||||
|
{
|
||||||
|
var shootInput = ClientGameplayInputFlow.CreateShootInput(
|
||||||
|
"player-1",
|
||||||
|
21,
|
||||||
|
new Vector3(2f, 0f, 0f));
|
||||||
|
|
||||||
|
Assert.That(shootInput.PlayerId, Is.EqualTo("player-1"));
|
||||||
|
Assert.That(shootInput.Tick, Is.EqualTo(21));
|
||||||
|
Assert.That(shootInput.DirX, Is.EqualTo(1f));
|
||||||
|
Assert.That(shootInput.DirY, Is.EqualTo(0f));
|
||||||
|
Assert.That(shootInput.TargetId, Is.EqualTo(string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void ClientPredictionBuffer_AuthoritativeState_PrunesAcknowledgedMoveInputs()
|
public void ClientPredictionBuffer_AuthoritativeState_PrunesAcknowledgedMoveInputs()
|
||||||
{
|
{
|
||||||
|
|
@ -149,4 +192,4 @@ namespace Tests.EditMode.Network
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-03-28
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The repository already has the shared MVP message split in place: `MoveInput` exists for high-frequency movement and `ShootInput` exists for reliable firing intent. The current Unity client path has only implemented part of that contract. `MovementComponent` captures movement every send interval, but it returns `null` for zero input, which means releasing input does not emit an authoritative stop update. The same component also drives immediate local prediction for the controlled player, while `NetworkManager` only exposes `SendMoveInput(...)` and has no shooting send path.
|
||||||
|
|
||||||
|
This change is cross-cutting enough to justify a design document because it touches Unity input capture, local prediction timing, message selection, and delivery-lane expectations together. It also needs to preserve the shared/client boundary already established by the networking architecture: Unity input polling stays in Unity-side scripts, while the shared networking layer continues to own message transport policy.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Define an MVP-safe client input flow that sends movement and shooting through `MoveInput` and `ShootInput` only.
|
||||||
|
- Preserve immediate local movement prediction for the controlled player.
|
||||||
|
- Ensure movement release emits one explicit zero-vector `MoveInput` so the server can stop authoritative motion.
|
||||||
|
- Add a `NetworkManager.SendShootInput(...)` API that keeps shooting traffic on the reliable lane through existing delivery-policy resolution.
|
||||||
|
- Keep local shooting feedback optional and cosmetic so authoritative combat remains server-driven.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Redesign the shared transport, message envelope, or delivery-policy system.
|
||||||
|
- Introduce `UnityEngine` dependencies into shared code under `Assets/Scripts/Network/`.
|
||||||
|
- Define full authoritative combat-result handling or client-side rollback for rejected shots.
|
||||||
|
- Rework remote-player interpolation or the later authoritative `PlayerState` application steps from the TODO.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision: Keep gameplay input ownership in Unity-side controlled-player components
|
||||||
|
Movement capture, release detection, and shooting input polling will stay in Unity-facing scripts such as `MovementComponent` or an adjacent controlled-player helper, with `NetworkManager` acting as the send boundary into shared networking code.
|
||||||
|
|
||||||
|
This keeps Unity polling (`Input.*`) out of shared networking code and matches the current architecture, where `MovementComponent` already owns local prediction and `NetworkManager` already owns message submission.
|
||||||
|
|
||||||
|
Alternative considered: move gameplay input orchestration into shared networking services. Rejected because shared code cannot depend on Unity input APIs and the TODO step only needs client-side MVP wiring, not a new host-agnostic input abstraction.
|
||||||
|
|
||||||
|
### Decision: Represent input release as an edge-triggered zero-vector `MoveInput`
|
||||||
|
The client will continue sampling movement every send interval, but it must distinguish between "still idle" and "just released input." On the transition from non-zero movement to idle, it will send one final `MoveInput` whose vector is zero. Continued idle frames will not keep spamming zero messages unless a later movement input starts and stops again.
|
||||||
|
|
||||||
|
This satisfies the authoritative-stop acceptance criteria without bloating sync traffic or changing the meaning of `MoveInput`.
|
||||||
|
|
||||||
|
Alternative considered: infer stop on the server from missing movement packets. Rejected because it couples stop timing to packet cadence and conflicts with the TODO requirement for an explicit stop message.
|
||||||
|
|
||||||
|
### Decision: Keep local movement prediction immediate and independent from send-path branching
|
||||||
|
The controlled player should continue applying local movement immediately from the latest captured input, including the zero-vector case on release, while the network send path decides whether that captured input should be transmitted this frame.
|
||||||
|
|
||||||
|
This preserves current feel and keeps prediction behavior stable even as the send rules become more explicit.
|
||||||
|
|
||||||
|
Alternative considered: simulate only after a message is queued for send. Rejected because it would delay local response and entangle presentation with networking cadence.
|
||||||
|
|
||||||
|
### Decision: Add a narrow `SendShootInput(...)` API and leave shot presentation outside authoritative gameplay
|
||||||
|
`NetworkManager` will gain a dedicated method for sending `ShootInput`, mirroring the current movement send API. The client capture path will construct `ShootInput` from local fire intent and rely on the existing delivery-policy resolver so the message remains on the reliable lane. Any local muzzle flash, animation, or other feedback remains optional and must not decide damage, hit confirmation, or death state.
|
||||||
|
|
||||||
|
This keeps the MVP send surface explicit and ensures the client no longer needs legacy broad gameplay messages for firing.
|
||||||
|
|
||||||
|
Alternative considered: reuse a generic "send gameplay action" entry point. Rejected because it weakens the split-message contract and leaves room for `PlayerAction`-style payloads to creep back into client code.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Risk] Edge-triggered stop detection can regress if input state tracking mixes "captured this frame" with "sent this frame." → Mitigation: define the release rule around the last non-zero captured movement state and cover it with regression tests.
|
||||||
|
- [Risk] Shooting capture may depend on an existing Unity scene/input setup that is less formalized than movement. → Mitigation: keep the MVP contract narrow, allow optional cosmetic feedback, and leave targeting details minimal when no authoritative target is selected.
|
||||||
|
- [Risk] Client scripts could accidentally reintroduce legacy gameplay messages while adding fire input. → Mitigation: codify the split-message-only rule in specs and tests around `NetworkManager` send APIs.
|
||||||
|
- [Risk] Overloading `MovementComponent` with too many responsibilities could make later TODO steps harder to implement. → Mitigation: keep this step focused on input capture/send semantics and defer broader controller refactors unless implementation friction proves they are necessary.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Update the specs so the MVP client-input contract, message selection rules, and lane expectations are explicit.
|
||||||
|
2. Implement Unity-side input changes for movement release detection, local prediction continuity, and shooting capture.
|
||||||
|
3. Add `NetworkManager.SendShootInput(...)` and remove any remaining client gameplay dependence on legacy broad messages.
|
||||||
|
4. Add regression coverage for stop-message emission, reliable shooting dispatch, and split-message-only gameplay sends.
|
||||||
|
5. Roll back, if needed, by removing the shooting send entry point and returning to movement-only capture while preserving the existing shared message definitions.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Which existing Unity input source should drive the initial `ShootInput` direction in MVP: pointer-derived aim, avatar facing, or a simpler fixed forward fallback?
|
||||||
|
- Should the first implementation keep shooting capture inside `MovementComponent`, or is a small adjacent controlled-player input script clearer once fire intent is added?
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The MVP protocol has already been split into `MoveInput` and `ShootInput`, but the client input loop still only emits non-zero movement updates and has no shooting send path. Step 2 is needed now so the client actually drives gameplay through the split message contract before later authoritative state and combat-result work depends on it.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Define the MVP client gameplay-input flow for movement capture, stop signaling, local prediction, and shooting input capture.
|
||||||
|
- Require the controlled player to send an explicit zero-vector `MoveInput` when local movement input is released so authoritative movement can stop cleanly.
|
||||||
|
- Add a `NetworkManager.SendShootInput(...)` path and require client gameplay actions to be sent only as `MoveInput` or `ShootInput`.
|
||||||
|
- Preserve immediate local movement prediction for the controlled player while keeping local shooting presentation optional and purely cosmetic.
|
||||||
|
- Add regression coverage for the client-side input flow and the delivery-lane expectations it depends on.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `client-gameplay-input`: Defines how the MVP client captures movement and shooting intent, predicts local movement, and sends gameplay actions through split message types.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `network-gameplay-message-types`: Tighten the gameplay message contract so client gameplay-action send paths use `MoveInput` and `ShootInput` directly instead of legacy broad messages such as `PlayerAction`.
|
||||||
|
- `network-sync-strategy`: Clarify that explicit zero-vector `MoveInput` updates remain valid sync-lane traffic while `ShootInput` continues to use the reliable lane from the client send path.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected Unity-side client code in `Assets/Scripts/MovementComponent.cs`, `Assets/Scripts/NetworkManager.cs`, and related local input/presentation scripts.
|
||||||
|
- Affected shared or integration expectations for gameplay message selection and delivery-lane behavior.
|
||||||
|
- Edit-mode regression tests under `Assets/Tests/EditMode/Network/`, plus any focused client-side tests needed for input-flow coverage.
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Controlled client movement input preserves immediate prediction and explicit stop signaling
|
||||||
|
The MVP client SHALL capture movement intent for the controlled player in Unity-side input code, apply local movement prediction immediately, and send `MoveInput` updates through the networking boundary. When movement input transitions from non-zero to idle, the client MUST send one final zero-vector `MoveInput` so authoritative movement can stop cleanly.
|
||||||
|
|
||||||
|
#### Scenario: Controlled player moves locally without waiting for the network
|
||||||
|
- **WHEN** the controlled player provides non-zero movement input
|
||||||
|
- **THEN** the client applies local movement prediction immediately for presentation
|
||||||
|
- **THEN** the client submits a `MoveInput` carrying the current player id, tick, and planar movement vector through the networking send path
|
||||||
|
|
||||||
|
#### Scenario: Releasing movement emits an explicit stop update
|
||||||
|
- **WHEN** the controlled player releases movement input after previously providing non-zero movement
|
||||||
|
- **THEN** the client sends exactly one final `MoveInput` whose movement vector is zero
|
||||||
|
- **THEN** local predicted movement also stops immediately without waiting for authoritative correction
|
||||||
|
|
||||||
|
### Requirement: Controlled client captures shooting intent as a dedicated gameplay input
|
||||||
|
The MVP client SHALL capture local fire intent separately from movement and translate that intent into `ShootInput` messages rather than overloading movement or generic gameplay-action payloads.
|
||||||
|
|
||||||
|
#### Scenario: Firing produces a shoot input message
|
||||||
|
- **WHEN** the controlled player triggers a fire action
|
||||||
|
- **THEN** the client constructs a `ShootInput` containing the current player id, tick, and aim direction used by the MVP client flow
|
||||||
|
- **THEN** the message is sent through a dedicated shooting send path instead of a legacy generic gameplay-action message
|
||||||
|
|
||||||
|
### Requirement: Local shooting presentation remains cosmetic
|
||||||
|
The MVP client SHALL treat any immediate local shooting feedback as optional cosmetic presentation and MUST NOT use it to finalize authoritative combat outcomes.
|
||||||
|
|
||||||
|
#### Scenario: Cosmetic firing feedback does not decide gameplay truth
|
||||||
|
- **WHEN** the client plays local muzzle flash, animation, or similar fire feedback before server confirmation
|
||||||
|
- **THEN** that feedback does not apply authoritative damage, hit confirmation, or death resolution locally
|
||||||
|
- **THEN** gameplay truth remains dependent on authoritative server messages
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Client gameplay actions use split gameplay messages directly
|
||||||
|
The client-facing gameplay send path SHALL express MVP gameplay actions directly as `MoveInput` and `ShootInput`. Controlled-player movement and firing MUST NOT depend on legacy broad gameplay messages such as `PlayerAction`.
|
||||||
|
|
||||||
|
#### Scenario: Client movement uses MoveInput directly
|
||||||
|
- **WHEN** the controlled client sends gameplay movement intent
|
||||||
|
- **THEN** the send path uses `MoveInput`
|
||||||
|
- **THEN** the client does not wrap that movement intent in `PlayerAction` or another broad gameplay payload
|
||||||
|
|
||||||
|
#### Scenario: Client firing uses ShootInput directly
|
||||||
|
- **WHEN** the controlled client sends gameplay firing intent
|
||||||
|
- **THEN** the send path uses `ShootInput`
|
||||||
|
- **THEN** the client does not wrap that firing intent in `PlayerAction` or another broad gameplay payload
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Client gameplay input preserves movement and shooting lane semantics
|
||||||
|
The client gameplay-input flow SHALL preserve the MVP delivery-lane contract when sending gameplay actions. Explicit zero-vector `MoveInput` updates generated on input release MUST remain valid high-frequency sync traffic, and `ShootInput` generated from local fire intent MUST use the reliable ordered lane.
|
||||||
|
|
||||||
|
#### Scenario: Stop movement update remains sync traffic
|
||||||
|
- **WHEN** the controlled client sends a zero-vector `MoveInput` after releasing movement input
|
||||||
|
- **THEN** the message is still treated as `MoveInput` for delivery-policy resolution
|
||||||
|
- **THEN** the networking stack routes it through the high-frequency sync lane when one is configured
|
||||||
|
|
||||||
|
#### Scenario: Shoot input remains reliable traffic
|
||||||
|
- **WHEN** the controlled client sends `ShootInput` from the MVP fire-input path
|
||||||
|
- **THEN** the networking stack resolves that message to the reliable ordered lane
|
||||||
|
- **THEN** firing intent does not share the latest-wins sync delivery behavior used for movement updates
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
## 1. Update Controlled-Player Input Capture
|
||||||
|
|
||||||
|
- [x] 1.1 Refine the controlled-player movement input flow so releasing input emits one zero-vector `MoveInput` while continued idle frames do not spam duplicate stop packets.
|
||||||
|
- [x] 1.2 Preserve immediate local movement prediction for the controlled player across both non-zero movement and release-to-stop transitions.
|
||||||
|
- [x] 1.3 Add an MVP shooting input capture path in the Unity-side controlled-player flow, including direction/target defaults that fit the current scene setup.
|
||||||
|
|
||||||
|
## 2. Align Network Send Boundaries With Split Gameplay Messages
|
||||||
|
|
||||||
|
- [x] 2.1 Add `NetworkManager.SendShootInput(...)` and route the new client fire path through `ShootInput`.
|
||||||
|
- [x] 2.2 Ensure client gameplay actions are sent only as `MoveInput` and `ShootInput`, with no remaining controlled-player dependence on legacy broad gameplay messages such as `PlayerAction`.
|
||||||
|
- [x] 2.3 Keep any local shooting feedback optional and cosmetic so authoritative combat still depends on server-driven messages.
|
||||||
|
|
||||||
|
## 3. Verify MVP Input-Flow Regressions
|
||||||
|
|
||||||
|
- [x] 3.1 Add or update tests covering explicit stop-message emission and the split-message-only gameplay send path.
|
||||||
|
- [x] 3.2 Add or update tests covering that `ShootInput` from the client path resolves to the reliable lane while `MoveInput` stop updates remain sync-lane traffic.
|
||||||
|
- [x] 3.3 Run `dotnet build Network.EditMode.Tests.csproj -v minimal` and `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal` after implementation.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
# client-gameplay-input Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Define how the controlled Unity client captures MVP gameplay intent, preserves immediate local prediction, and sends movement and shooting through split gameplay messages.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Controlled client movement input preserves immediate prediction and explicit stop signaling
|
||||||
|
The MVP client SHALL capture movement intent for the controlled player in Unity-side input code, apply local movement prediction immediately, and send `MoveInput` updates through the networking boundary. When movement input transitions from non-zero to idle, the client MUST send one final zero-vector `MoveInput` so authoritative movement can stop cleanly.
|
||||||
|
|
||||||
|
#### Scenario: Controlled player moves locally without waiting for the network
|
||||||
|
- **WHEN** the controlled player provides non-zero movement input
|
||||||
|
- **THEN** the client applies local movement prediction immediately for presentation
|
||||||
|
- **THEN** the client submits a `MoveInput` carrying the current player id, tick, and planar movement vector through the networking send path
|
||||||
|
|
||||||
|
#### Scenario: Releasing movement emits an explicit stop update
|
||||||
|
- **WHEN** the controlled player releases movement input after previously providing non-zero movement
|
||||||
|
- **THEN** the client sends exactly one final `MoveInput` whose movement vector is zero
|
||||||
|
- **THEN** local predicted movement also stops immediately without waiting for authoritative correction
|
||||||
|
|
||||||
|
### Requirement: Controlled client captures shooting intent as a dedicated gameplay input
|
||||||
|
The MVP client SHALL capture local fire intent separately from movement and translate that intent into `ShootInput` messages rather than overloading movement or generic gameplay-action payloads.
|
||||||
|
|
||||||
|
#### Scenario: Firing produces a shoot input message
|
||||||
|
- **WHEN** the controlled player triggers a fire action
|
||||||
|
- **THEN** the client constructs a `ShootInput` containing the current player id, tick, and aim direction used by the MVP client flow
|
||||||
|
- **THEN** the message is sent through a dedicated shooting send path instead of a legacy generic gameplay-action message
|
||||||
|
|
||||||
|
### Requirement: Local shooting presentation remains cosmetic
|
||||||
|
The MVP client SHALL treat any immediate local shooting feedback as optional cosmetic presentation and MUST NOT use it to finalize authoritative combat outcomes.
|
||||||
|
|
||||||
|
#### Scenario: Cosmetic firing feedback does not decide gameplay truth
|
||||||
|
- **WHEN** the client plays local muzzle flash, animation, or similar fire feedback before server confirmation
|
||||||
|
- **THEN** that feedback does not apply authoritative damage, hit confirmation, or death resolution locally
|
||||||
|
- **THEN** gameplay truth remains dependent on authoritative server messages
|
||||||
|
|
@ -38,3 +38,16 @@ The shared networking contract SHALL define the MVP payload fields for gameplay
|
||||||
- **WHEN** client or server code constructs or parses `CombatEvent`
|
- **WHEN** client or server code constructs or parses `CombatEvent`
|
||||||
- **THEN** the message exposes `tick`, `eventType`, `attackerId`, `targetId`, `damage`, and `hitPosition`
|
- **THEN** the message exposes `tick`, `eventType`, `attackerId`, `targetId`, `damage`, and `hitPosition`
|
||||||
- **THEN** `CombatEventType` provides explicit combat-result categories for interpreting that event payload
|
- **THEN** `CombatEventType` provides explicit combat-result categories for interpreting that event payload
|
||||||
|
|
||||||
|
### Requirement: Client gameplay actions use split gameplay messages directly
|
||||||
|
The client-facing gameplay send path SHALL express MVP gameplay actions directly as `MoveInput` and `ShootInput`. Controlled-player movement and firing MUST NOT depend on legacy broad gameplay messages such as `PlayerAction`.
|
||||||
|
|
||||||
|
#### Scenario: Client movement uses MoveInput directly
|
||||||
|
- **WHEN** the controlled client sends gameplay movement intent
|
||||||
|
- **THEN** the send path uses `MoveInput`
|
||||||
|
- **THEN** the client does not wrap that movement intent in `PlayerAction` or another broad gameplay payload
|
||||||
|
|
||||||
|
#### Scenario: Client firing uses ShootInput directly
|
||||||
|
- **WHEN** the controlled client sends gameplay firing intent
|
||||||
|
- **THEN** the send path uses `ShootInput`
|
||||||
|
- **THEN** the client does not wrap that firing intent in `PlayerAction` or another broad gameplay payload
|
||||||
|
|
|
||||||
|
|
@ -71,3 +71,16 @@ The networking stack SHALL route sync-designated traffic through the sync transp
|
||||||
#### Scenario: Dedicated sync transport unavailable
|
#### Scenario: Dedicated sync transport unavailable
|
||||||
- **WHEN** sync-designated traffic is sent from a session whose integration wiring does not include a sync transport
|
- **WHEN** sync-designated traffic is sent from a session whose integration wiring does not include a sync transport
|
||||||
- **THEN** the traffic SHALL be dispatched on the primary reliable transport without failing session operation
|
- **THEN** the traffic SHALL be dispatched on the primary reliable transport without failing session operation
|
||||||
|
|
||||||
|
### Requirement: Client gameplay input preserves movement and shooting lane semantics
|
||||||
|
The client gameplay-input flow SHALL preserve the MVP delivery-lane contract when sending gameplay actions. Explicit zero-vector `MoveInput` updates generated on input release MUST remain valid high-frequency sync traffic, and `ShootInput` generated from local fire intent MUST use the reliable ordered lane.
|
||||||
|
|
||||||
|
#### Scenario: Stop movement update remains sync traffic
|
||||||
|
- **WHEN** the controlled client sends a zero-vector `MoveInput` after releasing movement input
|
||||||
|
- **THEN** the message is still treated as `MoveInput` for delivery-policy resolution
|
||||||
|
- **THEN** the networking stack routes it through the high-frequency sync lane when one is configured
|
||||||
|
|
||||||
|
#### Scenario: Shoot input remains reliable traffic
|
||||||
|
- **WHEN** the controlled client sends `ShootInput` from the MVP fire-input path
|
||||||
|
- **THEN** the networking stack resolves that message to the reliable ordered lane
|
||||||
|
- **THEN** firing intent does not share the latest-wins sync delivery behavior used for movement updates
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue