M1: player stand + control handoff + Cinemachine camera system

- Install Cinemachine 3.1.7
- Stand (raised platform behind north wall) with StandPoint_0/_1
- PlayerAvatar: seat-ownership drives the mode — hold a seat => teleport to your
  stand, freeze, refuse input (control transfers/locks to your tread); release =>
  teleport back, resume on-foot. Resolves the WASD overlap off one flag.
- CameraDirector on Main Camera: 4 Cinemachine vcams (AvatarFollow / RobotFollow
  / StandView / CinematicOrbit), Brain blends; V cycles; default auto-follows the
  seat flag (on-foot=Avatar, driving=Robot). Retires the old FollowCamera.
- Verified in play: claim->teleport+freeze+RobotFollow, release->return+AvatarFollow

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-07-12 20:44:18 +01:00
parent e93e6e76a3
commit e92be317f5
6 changed files with 490 additions and 25 deletions

View file

@ -1,4 +1,5 @@
using Unity.Netcode;
using Unity.Netcode.Components;
using UnityEngine;
using UnityEngine.InputSystem;
@ -6,25 +7,24 @@ namespace Jankenbots.Prototype
{
/// <summary>
/// JANKENBOTS — networked run-around PLAYER avatar (the rigged low-poly
/// character). This is the foundation for the timed junkyard "gather parts"
/// phase: each player controls one of these, runs around, and (later) hauls
/// scrap back to build the bot. Driving the finished bot happens from player
/// stands, so the avatar and the tread controls are separate systems.
/// character), PLUS the on-foot ↔ driving control handoff.
///
/// NETCODE: OWNER-authoritative. Only the owning client reads input and moves
/// its CharacterController; an <see cref="OwnerAuthNetworkTransform"/> on the
/// same object replicates the resulting motion to everyone. No host round-trip
/// on your own movement → it feels responsive.
/// 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.
///
/// ANIMATION: derived LOCALLY on every client from the avatar's measured
/// horizontal speed (position delta / dt), fed into the Animator's "Speed"
/// float, which drives an Idle→Walk→Run 1-D blend tree. Because observers
/// measure the same replicated motion the owner produces, the animation stays
/// in sync with zero extra network traffic — no NetworkAnimator needed.
/// NETCODE: OWNER-authoritative movement (see <see cref="OwnerAuthNetworkTransform"/>).
/// Teleports use NetworkTransform.Teleport so observers don't see a slide.
///
/// NOTE: reads WASD, same keys as the bot's TreadPart — fine while testing the
/// avatar in isolation; the real game separates "running" from "driving" via
/// the (future) player-stand flow, so a player is never doing both at once.
/// 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
@ -45,28 +45,41 @@ namespace Jankenbots.Prototype
[Tooltip("Gravity (m/s^2, negative). Keeps the CharacterController grounded.")]
public float gravity = -20f;
[Header("Animation")]
[Tooltip("Speed value the Animator treats as full-run, for normalising the blend.")]
public float runAnimSpeed = 5f;
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)
{
@ -80,7 +93,20 @@ namespace Jankenbots.Prototype
{
if (!IsSpawned) return;
if (IsOwner) OwnerMove();
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();
}
// 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;
@ -91,6 +117,46 @@ namespace Jankenbots.Prototype
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;
@ -107,8 +173,7 @@ namespace Jankenbots.Prototype
if (input.sqrMagnitude > 1f) input.Normalize();
Vector3 desired = input * moveSpeed;
// Ramp velocity toward the target instead of snapping — this is what kills
// the "jerky/jarring" feel and lets the anim blend Idle→Walk→Run smoothly.
// 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);
@ -117,8 +182,6 @@ namespace Jankenbots.Prototype
_cc.Move((_horizVel + new Vector3(0f, _vy, 0f)) * Time.deltaTime);
// Face the way we're actually moving (uses the smoothed velocity, so turns
// ease too rather than snapping on a key change).
if (_horizVel.sqrMagnitude > 0.05f)
{
Quaternion target = Quaternion.LookRotation(new Vector3(_horizVel.x, 0f, _horizVel.z));