-
Notifications
You must be signed in to change notification settings - Fork 494
/
ClientInputSender.cs
600 lines (512 loc) · 25.5 KB
/
ClientInputSender.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
using System;
using Unity.BossRoom.Gameplay.Actions;
using Unity.BossRoom.Gameplay.Configuration;
using Unity.BossRoom.Gameplay.GameplayObjects;
using Unity.BossRoom.Gameplay.GameplayObjects.Character;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.Assertions;
using UnityEngine.EventSystems;
namespace Unity.BossRoom.Gameplay.UserInput
{
/// <summary>
/// Captures inputs for a character on a client and sends them to the server.
/// </summary>
[RequireComponent(typeof(ServerCharacter))]
public class ClientInputSender : NetworkBehaviour
{
const float k_MouseInputRaycastDistance = 100f;
//The movement input rate is capped at 40ms (or 25 fps). This provides a nice balance between responsiveness and
//upstream network conservation. This matters when holding down your mouse button to move.
const float k_MoveSendRateSeconds = 0.04f; //25 fps.
const float k_TargetMoveTimeout = 0.45f; //prevent moves for this long after targeting someone (helps prevent walking to the guy you clicked).
float m_LastSentMove;
// Cache raycast hit array so that we can use non alloc raycasts
readonly RaycastHit[] k_CachedHit = new RaycastHit[4];
// This is basically a constant but layer masks cannot be created in the constructor, that's why it's assigned int Awake.
LayerMask m_GroundLayerMask;
LayerMask m_ActionLayerMask;
const float k_MaxNavMeshDistance = 1f;
RaycastHitComparer m_RaycastHitComparer;
[SerializeField]
ServerCharacter m_ServerCharacter;
/// <summary>
/// This event fires at the time when an action request is sent to the server.
/// </summary>
public event Action<ActionRequestData> ActionInputEvent;
/// <summary>
/// This describes how a skill was requested. Skills requested via mouse click will do raycasts to determine their target; skills requested
/// in other matters will use the stateful target stored in NetworkCharacterState.
/// </summary>
public enum SkillTriggerStyle
{
None, //no skill was triggered.
MouseClick, //skill was triggered via mouse-click implying you should do a raycast from the mouse position to find a target.
Keyboard, //skill was triggered via a Keyboard press, implying target should be taken from the active target.
KeyboardRelease, //represents a released key.
UI, //skill was triggered from the UI, and similar to Keyboard, target should be inferred from the active target.
UIRelease, //represents letting go of the mouse-button on a UI button
}
bool IsReleaseStyle(SkillTriggerStyle style)
{
return style == SkillTriggerStyle.KeyboardRelease || style == SkillTriggerStyle.UIRelease;
}
/// <summary>
/// This struct essentially relays the call params of RequestAction to FixedUpdate. Recall that we may need to do raycasts
/// as part of doing the action, and raycasts done outside of FixedUpdate can give inconsistent results (otherwise we would
/// just expose PerformAction as a public method, and let it be called in whatever scoped it liked.
/// </summary>
/// <remarks>
/// Reference: https://answers.unity.com/questions/1141633/why-does-fixedupdate-work-when-update-doesnt.html
/// </remarks>
struct ActionRequest
{
public SkillTriggerStyle TriggerStyle;
public ActionID RequestedActionID;
public ulong TargetId;
}
/// <summary>
/// List of ActionRequests that have been received since the last FixedUpdate ran. This is a static array, to avoid allocs, and
/// because we don't really want to let this list grow indefinitely.
/// </summary>
readonly ActionRequest[] m_ActionRequests = new ActionRequest[5];
/// <summary>
/// Number of ActionRequests that have been queued since the last FixedUpdate.
/// </summary>
int m_ActionRequestCount;
BaseActionInput m_CurrentSkillInput;
bool m_MoveRequest;
Camera m_MainCamera;
public event Action<Vector3> ClientMoveEvent;
/// <summary>
/// Convenience getter that returns our CharacterData
/// </summary>
CharacterClass CharacterClass => m_ServerCharacter.CharacterClass;
[SerializeField]
PhysicsWrapper m_PhysicsWrapper;
public ActionState actionState1 { get; private set; }
public ActionState actionState2 { get; private set; }
public ActionState actionState3 { get; private set; }
public System.Action action1ModifiedCallback;
ServerCharacter m_TargetServerCharacter;
void Awake()
{
m_MainCamera = Camera.main;
}
public override void OnNetworkSpawn()
{
if (!IsClient || !IsOwner)
{
enabled = false;
// dont need to do anything else if not the owner
return;
}
m_ServerCharacter.TargetId.OnValueChanged += OnTargetChanged;
m_ServerCharacter.HeldNetworkObject.OnValueChanged += OnHeldNetworkObjectChanged;
if (CharacterClass.Skill1 &&
GameDataSource.Instance.TryGetActionPrototypeByID(CharacterClass.Skill1.ActionID, out var action1))
{
actionState1 = new ActionState() { actionID = action1.ActionID, selectable = true };
}
if (CharacterClass.Skill2 &&
GameDataSource.Instance.TryGetActionPrototypeByID(CharacterClass.Skill2.ActionID, out var action2))
{
actionState2 = new ActionState() { actionID = action2.ActionID, selectable = true };
}
if (CharacterClass.Skill3 &&
GameDataSource.Instance.TryGetActionPrototypeByID(CharacterClass.Skill3.ActionID, out var action3))
{
actionState3 = new ActionState() { actionID = action3.ActionID, selectable = true };
}
m_GroundLayerMask = LayerMask.GetMask(new[] { "Ground" });
m_ActionLayerMask = LayerMask.GetMask(new[] { "PCs", "NPCs", "Ground" });
m_RaycastHitComparer = new RaycastHitComparer();
}
public override void OnNetworkDespawn()
{
if (m_ServerCharacter)
{
m_ServerCharacter.TargetId.OnValueChanged -= OnTargetChanged;
m_ServerCharacter.HeldNetworkObject.OnValueChanged -= OnHeldNetworkObjectChanged;
}
if (m_TargetServerCharacter)
{
m_TargetServerCharacter.NetLifeState.LifeState.OnValueChanged -= OnTargetLifeStateChanged;
}
}
void OnTargetChanged(ulong previousValue, ulong newValue)
{
if (m_TargetServerCharacter)
{
m_TargetServerCharacter.NetLifeState.LifeState.OnValueChanged -= OnTargetLifeStateChanged;
}
m_TargetServerCharacter = null;
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(newValue, out var selection) &&
selection.TryGetComponent(out m_TargetServerCharacter))
{
m_TargetServerCharacter.NetLifeState.LifeState.OnValueChanged += OnTargetLifeStateChanged;
}
UpdateAction1();
}
void OnHeldNetworkObjectChanged(ulong previousValue, ulong newValue)
{
UpdateAction1();
}
void OnTargetLifeStateChanged(LifeState previousValue, LifeState newValue)
{
UpdateAction1();
}
void FinishSkill()
{
m_CurrentSkillInput = null;
}
void SendInput(ActionRequestData action)
{
ActionInputEvent?.Invoke(action);
m_ServerCharacter.RecvDoActionServerRPC(action);
}
void FixedUpdate()
{
//play all ActionRequests, in FIFO order.
for (int i = 0; i < m_ActionRequestCount; ++i)
{
if (m_CurrentSkillInput != null)
{
//actions requested while input is active are discarded, except for "Release" requests, which go through.
if (IsReleaseStyle(m_ActionRequests[i].TriggerStyle))
{
m_CurrentSkillInput.OnReleaseKey();
}
}
else if (!IsReleaseStyle(m_ActionRequests[i].TriggerStyle))
{
var actionPrototype = GameDataSource.Instance.GetActionPrototypeByID(m_ActionRequests[i].RequestedActionID);
if (actionPrototype.Config.ActionInput != null)
{
var skillPlayer = Instantiate(actionPrototype.Config.ActionInput);
skillPlayer.Initiate(m_ServerCharacter, m_PhysicsWrapper.Transform.position, actionPrototype.ActionID, SendInput, FinishSkill);
m_CurrentSkillInput = skillPlayer;
}
else
{
PerformSkill(actionPrototype.ActionID, m_ActionRequests[i].TriggerStyle, m_ActionRequests[i].TargetId);
}
}
}
m_ActionRequestCount = 0;
if (EventSystem.current.currentSelectedGameObject != null)
{
return;
}
if (m_MoveRequest)
{
m_MoveRequest = false;
if ((Time.time - m_LastSentMove) > k_MoveSendRateSeconds)
{
m_LastSentMove = Time.time;
var ray = m_MainCamera.ScreenPointToRay(UnityEngine.Input.mousePosition);
var groundHits = Physics.RaycastNonAlloc(ray,
k_CachedHit,
k_MouseInputRaycastDistance,
m_GroundLayerMask);
if (groundHits > 0)
{
if (groundHits > 1)
{
// sort hits by distance
Array.Sort(k_CachedHit, 0, groundHits, m_RaycastHitComparer);
}
// verify point is indeed on navmesh surface
if (NavMesh.SamplePosition(k_CachedHit[0].point,
out var hit,
k_MaxNavMeshDistance,
NavMesh.AllAreas))
{
m_ServerCharacter.SendCharacterInputServerRpc(hit.position);
//Send our client only click request
ClientMoveEvent?.Invoke(hit.position);
}
}
}
}
}
/// <summary>
/// Perform a skill in response to some input trigger. This is the common method to which all input-driven skill plays funnel.
/// </summary>
/// <param name="actionID">The action you want to play. Note that "Skill1" may be overriden contextually depending on the target.</param>
/// <param name="triggerStyle">What sort of input triggered this skill?</param>
/// <param name="targetId">(optional) Pass in a specific networkID to target for this action</param>
void PerformSkill(ActionID actionID, SkillTriggerStyle triggerStyle, ulong targetId = 0)
{
Transform hitTransform = null;
if (targetId != 0)
{
// if a targetId is given, try to find the object
NetworkObject targetNetObj;
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(targetId, out targetNetObj))
{
hitTransform = targetNetObj.transform;
}
}
else
{
// otherwise try to find an object under the input position
int numHits = 0;
if (triggerStyle == SkillTriggerStyle.MouseClick)
{
var ray = m_MainCamera.ScreenPointToRay(UnityEngine.Input.mousePosition);
numHits = Physics.RaycastNonAlloc(ray, k_CachedHit, k_MouseInputRaycastDistance, m_ActionLayerMask);
}
int networkedHitIndex = -1;
for (int i = 0; i < numHits; i++)
{
if (k_CachedHit[i].transform.GetComponentInParent<NetworkObject>())
{
networkedHitIndex = i;
break;
}
}
hitTransform = networkedHitIndex >= 0 ? k_CachedHit[networkedHitIndex].transform : null;
}
if (GetActionRequestForTarget(hitTransform, actionID, triggerStyle, out ActionRequestData playerAction))
{
//Don't trigger our move logic for a while. This protects us from moving just because we clicked on them to target them.
m_LastSentMove = Time.time + k_TargetMoveTimeout;
SendInput(playerAction);
}
else if (!GameDataSource.Instance.GetActionPrototypeByID(actionID).IsGeneralTargetAction)
{
// clicked on nothing... perform an "untargeted" attack on the spot they clicked on.
// (Different Actions will deal with this differently. For some, like archer arrows, this will fire an arrow
// in the desired direction. For others, like mage's bolts, this will fire a "miss" projectile at the spot clicked on.)
var data = new ActionRequestData();
PopulateSkillRequest(k_CachedHit[0].point, actionID, ref data);
SendInput(data);
}
}
/// <summary>
/// When you right-click on something you will want to do contextually different things. For example you might attack an enemy,
/// but revive a friend. You might also decide to do nothing (e.g. right-clicking on a friend who hasn't FAINTED).
/// </summary>
/// <param name="hit">The Transform of the entity we clicked on, or null if none.</param>
/// <param name="actionID">The Action to build for</param>
/// <param name="triggerStyle">How did this skill play get triggered? Mouse, Keyboard, UI etc.</param>
/// <param name="resultData">Out parameter that will be filled with the resulting action, if any.</param>
/// <returns>true if we should play an action, false otherwise. </returns>
bool GetActionRequestForTarget(Transform hit, ActionID actionID, SkillTriggerStyle triggerStyle, out ActionRequestData resultData)
{
resultData = new ActionRequestData();
var targetNetObj = hit != null ? hit.GetComponentInParent<NetworkObject>() : null;
//if we can't get our target from the submitted hit transform, get it from our stateful target in our ServerCharacter.
if (!targetNetObj && !GameDataSource.Instance.GetActionPrototypeByID(actionID).IsGeneralTargetAction)
{
ulong targetId = m_ServerCharacter.TargetId.Value;
NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(targetId, out targetNetObj);
}
//sanity check that this is indeed a valid target.
if (targetNetObj == null || !ActionUtils.IsValidTarget(targetNetObj.NetworkObjectId))
{
return false;
}
if (targetNetObj.TryGetComponent<ServerCharacter>(out var serverCharacter))
{
//Skill1 may be contextually overridden if it was generated from a mouse-click.
if (actionID == CharacterClass.Skill1.ActionID && triggerStyle == SkillTriggerStyle.MouseClick)
{
if (!serverCharacter.IsNpc && serverCharacter.LifeState == LifeState.Fainted)
{
//right-clicked on a downed ally--change the skill play to Revive.
actionID = GameDataSource.Instance.ReviveActionPrototype.ActionID;
}
}
}
Vector3 targetHitPoint;
if (PhysicsWrapper.TryGetPhysicsWrapper(targetNetObj.NetworkObjectId, out var movementContainer))
{
targetHitPoint = movementContainer.Transform.position;
}
else
{
targetHitPoint = targetNetObj.transform.position;
}
// record our target in case this action uses that info (non-targeted attacks will ignore this)
resultData.ActionID = actionID;
resultData.TargetIds = new ulong[] { targetNetObj.NetworkObjectId };
PopulateSkillRequest(targetHitPoint, actionID, ref resultData);
return true;
}
/// <summary>
/// Populates the ActionRequestData with additional information. The TargetIds of the action should already be set before calling this.
/// </summary>
/// <param name="hitPoint">The point in world space where the click ray hit the target.</param>
/// <param name="actionID">The action to perform (will be stamped on the resultData)</param>
/// <param name="resultData">The ActionRequestData to be filled out with additional information.</param>
void PopulateSkillRequest(Vector3 hitPoint, ActionID actionID, ref ActionRequestData resultData)
{
resultData.ActionID = actionID;
var actionConfig = GameDataSource.Instance.GetActionPrototypeByID(actionID).Config;
//most skill types should implicitly close distance. The ones that don't are explicitly set to false in the following switch.
resultData.ShouldClose = true;
// figure out the Direction in case we want to send it
Vector3 offset = hitPoint - m_PhysicsWrapper.Transform.position;
offset.y = 0;
Vector3 direction = offset.normalized;
switch (actionConfig.Logic)
{
//for projectile logic, infer the direction from the click position.
case ActionLogic.LaunchProjectile:
resultData.Direction = direction;
resultData.ShouldClose = false; //why? Because you could be lining up a shot, hoping to hit other people between you and your target. Moving you would be quite invasive.
return;
case ActionLogic.Melee:
resultData.Direction = direction;
return;
case ActionLogic.Target:
resultData.ShouldClose = false;
return;
case ActionLogic.Emote:
resultData.CancelMovement = true;
return;
case ActionLogic.RangedFXTargeted:
resultData.Position = hitPoint;
return;
case ActionLogic.DashAttack:
resultData.Position = hitPoint;
return;
case ActionLogic.PickUp:
resultData.CancelMovement = true;
resultData.ShouldQueue = false;
return;
}
}
/// <summary>
/// Request an action be performed. This will occur on the next FixedUpdate.
/// </summary>
/// <param name="actionID"> The action you'd like to perform. </param>
/// <param name="triggerStyle"> What input style triggered this action. </param>
/// <param name="targetId"> NetworkObjectId of target. </param>
public void RequestAction(ActionID actionID, SkillTriggerStyle triggerStyle, ulong targetId = 0)
{
Assert.IsNotNull(GameDataSource.Instance.GetActionPrototypeByID(actionID),
$"Action with actionID {actionID} must be contained in the Action prototypes of GameDataSource!");
if (m_ActionRequestCount < m_ActionRequests.Length)
{
m_ActionRequests[m_ActionRequestCount].RequestedActionID = actionID;
m_ActionRequests[m_ActionRequestCount].TriggerStyle = triggerStyle;
m_ActionRequests[m_ActionRequestCount].TargetId = targetId;
m_ActionRequestCount++;
}
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Alpha1))
{
RequestAction(actionState1.actionID, SkillTriggerStyle.Keyboard);
}
else if (Input.GetKeyUp(KeyCode.Alpha1))
{
RequestAction(actionState1.actionID, SkillTriggerStyle.KeyboardRelease);
}
if (Input.GetKeyDown(KeyCode.Alpha2))
{
RequestAction(actionState2.actionID, SkillTriggerStyle.Keyboard);
}
else if (Input.GetKeyUp(KeyCode.Alpha2))
{
RequestAction(actionState2.actionID, SkillTriggerStyle.KeyboardRelease);
}
if (Input.GetKeyDown(KeyCode.Alpha3))
{
RequestAction(actionState3.actionID, SkillTriggerStyle.Keyboard);
}
else if (Input.GetKeyUp(KeyCode.Alpha3))
{
RequestAction(actionState3.actionID, SkillTriggerStyle.KeyboardRelease);
}
if (Input.GetKeyDown(KeyCode.Alpha5))
{
RequestAction(GameDataSource.Instance.Emote1ActionPrototype.ActionID, SkillTriggerStyle.Keyboard);
}
if (Input.GetKeyDown(KeyCode.Alpha6))
{
RequestAction(GameDataSource.Instance.Emote2ActionPrototype.ActionID, SkillTriggerStyle.Keyboard);
}
if (Input.GetKeyDown(KeyCode.Alpha7))
{
RequestAction(GameDataSource.Instance.Emote3ActionPrototype.ActionID, SkillTriggerStyle.Keyboard);
}
if (Input.GetKeyDown(KeyCode.Alpha8))
{
RequestAction(GameDataSource.Instance.Emote4ActionPrototype.ActionID, SkillTriggerStyle.Keyboard);
}
if (!EventSystem.current.IsPointerOverGameObject() && m_CurrentSkillInput == null)
{
//IsPointerOverGameObject() is a simple way to determine if the mouse is over a UI element. If it is, we don't perform mouse input logic,
//to model the button "blocking" mouse clicks from falling through and interacting with the world.
if (Input.GetMouseButtonDown(1))
{
RequestAction(CharacterClass.Skill1.ActionID, SkillTriggerStyle.MouseClick);
}
if (Input.GetMouseButtonDown(0))
{
RequestAction(GameDataSource.Instance.GeneralTargetActionPrototype.ActionID, SkillTriggerStyle.MouseClick);
}
else if (Input.GetMouseButton(0))
{
m_MoveRequest = true;
}
}
}
void UpdateAction1()
{
var isHoldingNetworkObject =
NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(m_ServerCharacter.HeldNetworkObject.Value,
out var heldNetworkObject);
NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(m_ServerCharacter.TargetId.Value,
out var selection);
var isSelectable = true;
if (isHoldingNetworkObject)
{
// show drop!
actionState1.actionID = GameDataSource.Instance.DropActionPrototype.ActionID;
}
else if ((m_ServerCharacter.TargetId.Value != 0
&& selection != null
&& selection.TryGetComponent(out PickUpState pickUpState))
)
{
// special case: targeting a pickup-able item or holding a pickup object
actionState1.actionID = GameDataSource.Instance.PickUpActionPrototype.ActionID;
}
else if (m_ServerCharacter.TargetId.Value != 0
&& selection != null
&& selection.NetworkObjectId != m_ServerCharacter.NetworkObjectId
&& selection.TryGetComponent(out ServerCharacter charState)
&& !charState.IsNpc)
{
// special case: when we have a player selected, we change the meaning of the basic action
// we have another player selected! In that case we want to reflect that our basic Action is a Revive, not an attack!
// But we need to know if the player is alive... if so, the button should be disabled (for better player communication)
actionState1.actionID = GameDataSource.Instance.ReviveActionPrototype.ActionID;
isSelectable = charState.NetLifeState.LifeState.Value != LifeState.Alive;
}
else
{
actionState1.SetActionState(CharacterClass.Skill1.ActionID);
}
actionState1.selectable = isSelectable;
action1ModifiedCallback?.Invoke();
}
public class ActionState
{
public ActionID actionID { get; internal set; }
public bool selectable { get; internal set; }
internal void SetActionState(ActionID newActionID, bool isSelectable = true)
{
actionID = newActionID;
selectable = isSelectable;
}
}
}
}