- CameraDirector: AvatarFollow now uses always-on mouse-look (no RMB) with a locked/hidden cursor; Esc toggles the cursor free; RobotFollow keeps RMB-look. - PlayerAvatar: movement is camera-relative (W = camera forward, character turns to face where it runs) instead of world/cardinal. - PlayerAvatar: E claims a tread on foot, Q leaves while seated (locked cursor makes the OnGUI buttons unclickable). - Verified in play: cursor locks in on-foot mode, camera-relative basis correct, no runtime errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
213 lines
9.1 KiB
C#
213 lines
9.1 KiB
C#
using Unity.Netcode;
|
|
using Unity.Netcode.Components;
|
|
using UnityEngine;
|
|
using UnityEngine.InputSystem;
|
|
|
|
namespace Jankenbots.Prototype
|
|
{
|
|
/// <summary>
|
|
/// JANKENBOTS — networked run-around PLAYER avatar (the rigged low-poly
|
|
/// character), PLUS the on-foot ↔ driving control handoff.
|
|
///
|
|
/// CONTROL MODEL (the spine): a player is in exactly one mode, derived from a
|
|
/// single fact — do I currently hold a bot seat? (<see cref="SeatManager"/>).
|
|
/// • ON FOOT (no seat) → WASD drives THIS avatar; free-roaming.
|
|
/// • DRIVING (holds a seat)→ avatar TELEPORTS to its player stand and FREEZES;
|
|
/// it refuses all input, so control transfers and
|
|
/// locks to the claimed tread (TreadPart already only
|
|
/// listens when you hold its seat). Release the seat →
|
|
/// teleport back and resume on-foot control.
|
|
/// This is what resolves the WASD overlap: PlayerAvatar and TreadPart become
|
|
/// mutually exclusive off one flag — never both live at once.
|
|
///
|
|
/// NETCODE: OWNER-authoritative movement (see <see cref="OwnerAuthNetworkTransform"/>).
|
|
/// Teleports use NetworkTransform.Teleport so observers don't see a slide.
|
|
///
|
|
/// ANIMATION: derived locally on every client from measured horizontal speed →
|
|
/// Animator "Speed" → Idle/Walk/Run blend (stays in sync with no extra traffic).
|
|
/// </summary>
|
|
[RequireComponent(typeof(CharacterController))]
|
|
public class PlayerAvatar : NetworkBehaviour
|
|
{
|
|
[Header("Movement")]
|
|
[Tooltip("Run speed in m/s.")]
|
|
public float moveSpeed = 5f;
|
|
|
|
[Tooltip("How fast the avatar ramps UP to full speed (m/s^2). Lower = floatier start.")]
|
|
public float acceleration = 45f;
|
|
|
|
[Tooltip("How fast the avatar ramps DOWN to a stop (m/s^2). Higher = snappier stop.")]
|
|
public float deceleration = 60f;
|
|
|
|
[Tooltip("How fast the avatar turns to face its movement direction (deg/s).")]
|
|
public float turnSpeed = 720f;
|
|
|
|
[Tooltip("Gravity (m/s^2, negative). Keeps the CharacterController grounded.")]
|
|
public float gravity = -20f;
|
|
|
|
CharacterController _cc;
|
|
Animator _anim;
|
|
NetworkTransform _nt;
|
|
SeatManager _seats;
|
|
Transform _standLeft, _standRight; // where each pilot teleports (found by name)
|
|
|
|
float _vy; // vertical velocity (gravity)
|
|
Vector3 _horizVel; // smoothed horizontal velocity (ramped, not instant)
|
|
Vector3 _lastPos;
|
|
float _animSpeed;
|
|
|
|
bool _seated; // am I (owner) currently piloting a tread?
|
|
Vector3 _returnPos; // where to drop me back when I leave the seat
|
|
bool _hasReturn;
|
|
|
|
static readonly int SpeedHash = Animator.StringToHash("Speed");
|
|
|
|
/// <summary>True while the LOCAL owner is seated/driving — read by the camera director.</summary>
|
|
public bool IsSeatedLocal => _seated;
|
|
|
|
void Awake()
|
|
{
|
|
_cc = GetComponent<CharacterController>();
|
|
_anim = GetComponentInChildren<Animator>();
|
|
_nt = GetComponent<NetworkTransform>();
|
|
_lastPos = transform.position;
|
|
}
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
_lastPos = transform.position;
|
|
_seats = FindFirstObjectByType<SeatManager>();
|
|
var sl = GameObject.Find("StandPoint_0"); if (sl != null) _standLeft = sl.transform;
|
|
var sr = GameObject.Find("StandPoint_1"); if (sr != null) _standRight = sr.transform;
|
|
|
|
// Fan owners out a little so multiple players don't stack on the origin.
|
|
if (IsOwner)
|
|
{
|
|
float a = OwnerClientId * 1.3f;
|
|
transform.position += new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * (2f + OwnerClientId);
|
|
_lastPos = transform.position;
|
|
}
|
|
}
|
|
|
|
void Update()
|
|
{
|
|
if (!IsSpawned) return;
|
|
|
|
if (IsOwner)
|
|
{
|
|
// Derive mode from seat ownership and handle the transition.
|
|
SeatManager.Seat? seat = LocalSeat();
|
|
bool wantSeated = seat.HasValue;
|
|
if (wantSeated != _seated)
|
|
{
|
|
if (wantSeated) EnterSeated(seat.Value);
|
|
else ExitSeated();
|
|
}
|
|
|
|
// Claim / leave a tread by KEY. On-foot mouse-look locks the cursor, so
|
|
// the OnGUI Claim/Leave buttons aren't clickable — E claims, Q leaves.
|
|
var kb = Keyboard.current;
|
|
if (_seats != null && kb != null)
|
|
{
|
|
if (!_seated && kb.eKey.wasPressedThisFrame) _seats.ClaimSeatRpc();
|
|
else if (_seated && kb.qKey.wasPressedThisFrame) _seats.ReleaseSeatRpc();
|
|
}
|
|
|
|
// On foot → drive the avatar. Seated → do nothing (control is on the tread).
|
|
if (!_seated) OwnerMove();
|
|
}
|
|
|
|
// Animation on EVERY client, from measured horizontal speed.
|
|
Vector3 delta = transform.position - _lastPos;
|
|
delta.y = 0f;
|
|
float measured = delta.magnitude / Mathf.Max(Time.deltaTime, 1e-4f);
|
|
_lastPos = transform.position;
|
|
_animSpeed = Mathf.Lerp(_animSpeed, measured, 12f * Time.deltaTime);
|
|
if (_anim != null) _anim.SetFloat(SpeedHash, _animSpeed);
|
|
}
|
|
|
|
// Which seat (if any) the local player holds — the single source of truth for mode.
|
|
SeatManager.Seat? LocalSeat()
|
|
{
|
|
if (_seats == null || NetworkManager.Singleton == null) return null;
|
|
ulong me = NetworkManager.Singleton.LocalClientId;
|
|
if (_seats.PilotOf(SeatManager.Seat.Left) == me) return SeatManager.Seat.Left;
|
|
if (_seats.PilotOf(SeatManager.Seat.Right) == me) return SeatManager.Seat.Right;
|
|
return null;
|
|
}
|
|
|
|
void EnterSeated(SeatManager.Seat seat)
|
|
{
|
|
_seated = true;
|
|
_returnPos = transform.position; // remember where I left the arena
|
|
_hasReturn = true;
|
|
_horizVel = Vector3.zero; _vy = 0f;
|
|
|
|
Transform sp = seat == SeatManager.Seat.Left ? _standLeft : _standRight;
|
|
if (sp != null) TeleportTo(sp.position, sp.rotation);
|
|
}
|
|
|
|
void ExitSeated()
|
|
{
|
|
_seated = false;
|
|
_horizVel = Vector3.zero; _vy = 0f;
|
|
if (_hasReturn) TeleportTo(_returnPos + Vector3.up * 0.2f, transform.rotation);
|
|
}
|
|
|
|
// Interpolation-free teleport (CharacterController must be off to move it directly;
|
|
// NetworkTransform.Teleport keeps observers from seeing a slide across the map).
|
|
void TeleportTo(Vector3 pos, Quaternion rot)
|
|
{
|
|
bool ccWas = _cc.enabled;
|
|
_cc.enabled = false;
|
|
transform.SetPositionAndRotation(pos, rot);
|
|
_cc.enabled = ccWas;
|
|
_lastPos = pos;
|
|
if (_nt != null) _nt.Teleport(pos, rot, transform.localScale);
|
|
}
|
|
|
|
void OwnerMove()
|
|
{
|
|
var kb = Keyboard.current;
|
|
float h = 0f, v = 0f;
|
|
if (kb != null)
|
|
{
|
|
if (kb.aKey.isPressed || kb.leftArrowKey.isPressed) h -= 1f;
|
|
if (kb.dKey.isPressed || kb.rightArrowKey.isPressed) h += 1f;
|
|
if (kb.wKey.isPressed || kb.upArrowKey.isPressed) v += 1f;
|
|
if (kb.sKey.isPressed || kb.downArrowKey.isPressed) v -= 1f;
|
|
}
|
|
|
|
// CAMERA-RELATIVE movement: W goes the way the camera (mouse) is looking,
|
|
// not world +Z. Basis is the active game camera flattened onto the ground.
|
|
Vector3 camF = Vector3.forward, camR = Vector3.right;
|
|
var cam = Camera.main;
|
|
if (cam != null)
|
|
{
|
|
camF = Vector3.ProjectOnPlane(cam.transform.forward, Vector3.up);
|
|
if (camF.sqrMagnitude < 1e-4f) camF = Vector3.ProjectOnPlane(cam.transform.up, Vector3.up); // looking straight down
|
|
camF.Normalize();
|
|
camR = Vector3.ProjectOnPlane(cam.transform.right, Vector3.up).normalized;
|
|
}
|
|
|
|
Vector3 input = camR * h + camF * v;
|
|
if (input.sqrMagnitude > 1f) input.Normalize();
|
|
Vector3 desired = input * moveSpeed;
|
|
|
|
// Ramp velocity toward the target instead of snapping (smooth start/stop).
|
|
float rate = input.sqrMagnitude > 0.001f ? acceleration : deceleration;
|
|
_horizVel = Vector3.MoveTowards(_horizVel, desired, rate * Time.deltaTime);
|
|
|
|
if (_cc.isGrounded && _vy < 0f) _vy = -2f;
|
|
_vy += gravity * Time.deltaTime;
|
|
|
|
_cc.Move((_horizVel + new Vector3(0f, _vy, 0f)) * Time.deltaTime);
|
|
|
|
if (_horizVel.sqrMagnitude > 0.05f)
|
|
{
|
|
Quaternion target = Quaternion.LookRotation(new Vector3(_horizVel.x, 0f, _horizVel.z));
|
|
transform.rotation = Quaternion.RotateTowards(transform.rotation, target, turnSpeed * Time.deltaTime);
|
|
}
|
|
}
|
|
}
|
|
}
|