M1: build shared 2-tread bot; rewire TreadPart to seat-based gating

- Rewrote TreadPart: seat-based input gating (was mis-wired to NetworkObject
  IsOwner, which needs per-tread ownership that NGO forbids for nested
  NetworkObjects). Both treads now share the chassis NetworkObject; a client
  drives the tread whose SeatManager seat it holds, host re-validates OwnsSeat
  on a RequireOwnership=false RPC. Also swapped to Keyboard.current (New input).
- Moved SeatManager OnGUI below the connectivity panel (was overlapping).
- M1_Arena: in-scene Bot NetworkObject = chassis (Rigidbody 60kg + NetworkObject
  + NetworkTransform + NetworkRigidbody + SeatManager) + Tread_L/Tread_R children
  (TreadPart, world offset +/-1.2 X for differential drive).
- Solo-verified: bot auto-spawns in-scene, seat claim + input gating wired.

Controls: W/Up throttle, Shift pivot, A/D lean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-07-11 00:13:41 +01:00
parent d42b42f269
commit e8d72e4649
3 changed files with 615 additions and 121 deletions

View file

@ -338,6 +338,522 @@ Transform:
m_Children: [] m_Children: []
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &781174154
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 781174160}
- component: {fileID: 781174159}
- component: {fileID: 781174158}
- component: {fileID: 781174157}
- component: {fileID: 781174156}
- component: {fileID: 781174155}
- component: {fileID: 781174163}
- component: {fileID: 781174162}
- component: {fileID: 781174161}
m_Layer: 0
m_Name: Bot
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &781174155
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 781174154}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
GlobalObjectIdHash: 1509253913
InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0
Ownership: 1
AlwaysReplicateAsRoot: 0
SynchronizeTransform: 1
<InScenePlaced>k__BackingField: 0
ActiveSceneSynchronization: 0
SceneMigrationSynchronization: 0
SpawnWithObservers: 1
DontDestroyWithOwner: 0
AutoObjectParentSync: 1
SyncOwnerTransformWhenParented: 1
AllowOwnerToParent: 0
--- !u!54 &781174156
Rigidbody:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 781174154}
serializedVersion: 5
m_Mass: 1
m_LinearDamping: 0
m_AngularDamping: 0.05
m_CenterOfMass: {x: 0, y: 0, z: 0}
m_InertiaTensor: {x: 1, y: 1, z: 1}
m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_ImplicitCom: 1
m_ImplicitTensor: 1
m_UseGravity: 1
m_IsKinematic: 0
m_Interpolate: 0
m_Constraints: 0
m_CollisionDetection: 0
--- !u!23 &781174157
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 781174154}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 1
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!65 &781174158
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 781174154}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!33 &781174159
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 781174154}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!4 &781174160
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 781174154}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 1, z: 0}
m_LocalScale: {x: 2, y: 1, z: 3}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1147751743}
- {fileID: 1190106215}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &781174161
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 781174154}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3dd8122938f14804f9e4ef7e494fb2f6, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Jankenbots.Prototype.SeatManager
ShowTopMostFoldoutHeaderGroup: 1
--- !u!114 &781174162
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 781174154}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f6c0be61502bb534f922ebb746851216, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.Components.NetworkRigidbody
ShowTopMostFoldoutHeaderGroup: 1
NetworkRigidbodyBaseExpanded: 0
UseRigidBodyForMotion: 0
AutoUpdateKinematicState: 1
AutoSetKinematicOnDespawn: 1
--- !u!114 &781174163
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 781174154}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e96cb6065543e43c4a752faaa1468eb1, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.Components.NetworkTransform
ShowTopMostFoldoutHeaderGroup: 1
NetworkTransformExpanded: 0
AutoOwnerAuthorityTickOffset: 1
PositionInterpolationType: 0
RotationInterpolationType: 0
ScaleInterpolationType: 0
PositionLerpSmoothing: 1
PositionMaxInterpolationTime: 0.1
RotationLerpSmoothing: 1
RotationMaxInterpolationTime: 0.1
ScaleLerpSmoothing: 1
ScaleMaxInterpolationTime: 0.1
AuthorityMode: 0
TickSyncChildren: 0
UseUnreliableDeltas: 0
SyncPositionX: 1
SyncPositionY: 1
SyncPositionZ: 1
SyncRotAngleX: 1
SyncRotAngleY: 1
SyncRotAngleZ: 1
SyncScaleX: 1
SyncScaleY: 1
SyncScaleZ: 1
PositionThreshold: 0.001
RotAngleThreshold: 0.01
ScaleThreshold: 0.01
UseQuaternionSynchronization: 0
UseQuaternionCompression: 0
UseHalfFloatPrecision: 0
InLocalSpace: 0
SwitchTransformSpaceWhenParented: 0
Interpolate: 1
SlerpPosition: 0
--- !u!1 &1147751742
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1147751743}
- component: {fileID: 1147751747}
- component: {fileID: 1147751746}
- component: {fileID: 1147751745}
- component: {fileID: 1147751744}
m_Layer: 0
m_Name: Tread_L
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1147751743
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1147751742}
serializedVersion: 2
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: -0.6, y: 0, z: 0}
m_LocalScale: {x: 0.2, y: 0.7, z: 0.9}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 781174160}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1147751744
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1147751742}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a07e6377a09865c4e8c2b59a637e7dd1, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Jankenbots.Prototype.TreadPart
ShowTopMostFoldoutHeaderGroup: 1
side: 0
chassis: {fileID: 0}
cruiseForce: 1200
throttleDeadzone: 0.08
pivotAnchorStrength: 2500
pivotAngularResistance: 400
leanTorque: 800
leanDeadzone: 0.08
--- !u!23 &1147751745
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1147751742}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 1
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!65 &1147751746
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1147751742}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!33 &1147751747
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1147751742}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!1 &1190106214
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1190106215}
- component: {fileID: 1190106219}
- component: {fileID: 1190106218}
- component: {fileID: 1190106217}
- component: {fileID: 1190106216}
m_Layer: 0
m_Name: Tread_R
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1190106215
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1190106214}
serializedVersion: 2
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0.6, y: 0, z: 0}
m_LocalScale: {x: 0.2, y: 0.7, z: 0.9}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 781174160}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1190106216
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1190106214}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a07e6377a09865c4e8c2b59a637e7dd1, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Jankenbots.Prototype.TreadPart
ShowTopMostFoldoutHeaderGroup: 1
side: 1
chassis: {fileID: 0}
cruiseForce: 1200
throttleDeadzone: 0.08
pivotAnchorStrength: 2500
pivotAngularResistance: 400
leanTorque: 800
leanDeadzone: 0.08
--- !u!23 &1190106217
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1190106214}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 1
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!65 &1190106218
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1190106214}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!33 &1190106219
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1190106214}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!1 &1336178141 --- !u!1 &1336178141
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -580,3 +1096,4 @@ SceneRoots:
- {fileID: 562137770} - {fileID: 562137770}
- {fileID: 1336178145} - {fileID: 1336178145}
- {fileID: 1571629442} - {fileID: 1571629442}
- {fileID: 781174160}

View file

@ -161,19 +161,21 @@ namespace Jankenbots.Prototype
Seat? mine = LocalSeat(); Seat? mine = LocalSeat();
string held = mine.HasValue ? mine.Value.ToString() : "none (spectator)"; string held = mine.HasValue ? mine.Value.ToString() : "none (spectator)";
const int w = 260, h = 78; // Sits BELOW the NetworkBootstrap connectivity panel (which occupies the
GUI.Box(new Rect(10, 10, w, h), "JANKENBOTS · seat"); // top-left ~260px) so the Claim button isn't hidden behind it.
GUI.Label(new Rect(20, 32, w - 20, 20), $"You hold: {held}"); const int x = 10, y = 300, w = 260, h = 78;
GUI.Label(new Rect(20, 52, w - 20, 20), GUI.Box(new Rect(x, y, w, h), "JANKENBOTS · seat");
GUI.Label(new Rect(x + 10, y + 22, w - 20, 20), $"You hold: {held}");
GUI.Label(new Rect(x + 10, y + 42, w - 20, 20),
$"Left: {SeatLabel(LeftPilot.Value)} Right: {SeatLabel(RightPilot.Value)}"); $"Left: {SeatLabel(LeftPilot.Value)} Right: {SeatLabel(RightPilot.Value)}");
// Client-side ask/release buttons (they just fire the ServerRpc). // Client-side ask/release buttons (they just fire the ServerRpc).
if (!mine.HasValue) if (!mine.HasValue)
{ {
if (GUI.Button(new Rect(w - 70, 30, 55, 20), "Claim")) if (GUI.Button(new Rect(x + w - 70, y + 20, 55, 20), "Claim"))
ClaimSeatRpc(); ClaimSeatRpc();
} }
else if (GUI.Button(new Rect(w - 70, 30, 55, 20), "Leave")) else if (GUI.Button(new Rect(x + w - 70, y + 20, 55, 20), "Leave"))
{ {
ReleaseSeatRpc(); ReleaseSeatRpc();
} }

View file

@ -1,5 +1,6 @@
using Unity.Netcode; using Unity.Netcode;
using UnityEngine; using UnityEngine;
using UnityEngine.InputSystem;
namespace Jankenbots.Prototype namespace Jankenbots.Prototype
{ {
@ -11,31 +12,28 @@ namespace Jankenbots.Prototype
/// clumsy body and try to make it go where they want together? /// clumsy body and try to make it go where they want together?
/// ///
/// ARCHITECTURE (host-authoritative, no prediction — see the M1 cheat-sheet): /// ARCHITECTURE (host-authoritative, no prediction — see the M1 cheat-sheet):
/// * There is ONE simulated Rigidbody: the CHASSIS. Both treads are just /// * There is ONE simulated Rigidbody + ONE NetworkObject: the CHASSIS. Both
/// force-emitters that push on that shared body. No tread has its own /// treads are plain CHILD GameObjects (no Rigidbody, no NetworkObject of their
/// Rigidbody. Differential drive "emerges" because the two treads apply /// own); each TreadPart is a NetworkBehaviour bound to the chassis' shared
/// their forces at different WORLD POSITIONS (left vs right of centre). /// NetworkObject. (NGO forbids nested NetworkObjects in a spawned prefab, so
/// * The owning pilot's client only READS input and ships {throttle, pivotHeld, /// per-tread ownership is impossible — we gate on the SEAT instead.)
/// lean} to the host via an RPC. It applies NOTHING locally. /// * Differential drive "emerges" because the two treads apply their forces at
/// * The host (IsServer) caches the latest input per tread and applies ALL /// different WORLD POSITIONS (left vs right of centre).
/// forces in FixedUpdate. NGO's NetworkRigidbody/NetworkTransform on the /// * SEAT-BASED input gating (via <see cref="SeatManager"/>): a client only reads
/// &amp; sends input for a tread whose seat it currently holds. The host re-checks
/// seat ownership before honoring any input RPC, so a client can't drive a tread
/// it doesn't pilot.
/// * The host (IsServer) caches the latest validated input per tread and applies
/// ALL forces in FixedUpdate. NGO's NetworkRigidbody/NetworkTransform on the
/// chassis replicates the resulting motion back to everyone. /// chassis replicates the resulting motion back to everyone.
/// ///
/// FEEL MODEL v0.1 — three deliberately "janky" verbs, each a literal force: /// FEEL MODEL v0.1 — three deliberately "janky" verbs, each a literal force:
/// (a) SNAP-TO-CRUISE THROTTLE — a tread does not have an analog gas pedal. /// (a) SNAP-TO-CRUISE THROTTLE — slam forward → a fixed CRUISE force; partial is
/// Push the stick fully forward and it commits to a fixed CRUISE force /// proportional but the intent is over-commitment (that's the comedy).
/// (chunky, momentum-y, a bit out of your hands). Partial stick is /// (b) LOCK-PIVOT — hold a button and THIS tread plants as an anchor; the bot
/// proportional so you CAN feather it, but the intent is "slam it to /// swings about it like a pinned foot.
/// cruise and live with the consequences" — that shared over-commitment /// (c) PASSIVE LEAN — sideways stick dumps a ballast torque to counter-roll the
/// is where the comedy of coordination comes from. /// top-heavy bot; a constant shared balancing chore.
/// (b) LOCK-PIVOT — hold a button and THIS tread plants itself as an anchor.
/// Its drive contribution is cancelled and we actively fight the chassis'
/// motion AT the tread's position, so the whole bot swings/rotates about
/// this tread like a pinned foot. Two pilots learn "you plant, I drive"
/// to turn on the spot.
/// (c) PASSIVE LEAN — this tall bot WANTS to tip over. Nudging the stick
/// sideways dumps a ballast torque along the drive axis to counter-roll,
/// a constant low-key balancing chore shared between pilots.
/// ///
/// Tuning fields are public so we can dial the feel live in the inspector while /// Tuning fields are public so we can dial the feel live in the inspector while
/// friends are playing. Numbers here are only sane starting points. /// friends are playing. Numbers here are only sane starting points.
@ -43,14 +41,13 @@ namespace Jankenbots.Prototype
[DisallowMultipleComponent] [DisallowMultipleComponent]
public class TreadPart : NetworkBehaviour public class TreadPart : NetworkBehaviour
{ {
// Which side of the bot this tread is. Purely descriptive for M1 (the actual // Which side of the bot this tread is. Drives the SEAT it maps to (Left/Right)
// left/right behaviour comes from where the tread SITS on the chassis, not // AND, because the tread physically SITS left/right of centre, the differential
// from this enum) — but it's handy for seat-assignment logs and inspector // drive. Set per-tread in the inspector.
// sanity, and lets us bias per-side tuning later if we want asymmetry.
public enum Side { Left, Right } public enum Side { Left, Right }
[Header("Identity")] [Header("Identity")]
[Tooltip("Which tread this is. Descriptive — real behaviour comes from world position on the chassis.")] [Tooltip("Which tread this is → which SeatManager seat it maps to, and (via its world position) which side of the chassis it pushes.")]
public Side side = Side.Left; public Side side = Side.Left;
[Header("Shared body")] [Header("Shared body")]
@ -59,75 +56,84 @@ namespace Jankenbots.Prototype
// ---- (a) SNAP-TO-CRUISE THROTTLE tuning ---------------------------------- // ---- (a) SNAP-TO-CRUISE THROTTLE tuning ----------------------------------
[Header("(a) Throttle — snap-to-cruise")] [Header("(a) Throttle — snap-to-cruise")]
[Tooltip("Force (Newtons) applied at full-forward stick. This is the 'cruise' the tread snaps to. Bigger = the bot lurches harder and is twic­e as hard to coordinate.")] [Tooltip("Force (Newtons) applied at full-forward stick. This is the 'cruise' the tread snaps to.")]
public float cruiseForce = 1200f; public float cruiseForce = 1200f;
[Tooltip("Below this stick magnitude we treat throttle as zero — kills drift/noise so a resting stick doesn't creep the bot.")] [Tooltip("Below this stick magnitude we treat throttle as zero.")]
[Range(0f, 0.5f)] [Range(0f, 0.5f)]
public float throttleDeadzone = 0.08f; public float throttleDeadzone = 0.08f;
// ---- (b) LOCK-PIVOT tuning ----------------------------------------------- // ---- (b) LOCK-PIVOT tuning -----------------------------------------------
[Header("(b) Lock-pivot — plant this tread as an anchor")] [Header("(b) Lock-pivot — plant this tread as an anchor")]
[Tooltip("How hard the planted tread resists the chassis sliding at its position. Higher = a crisper, more locked pivot; too high = the whole bot snaps rigidly and feels un-janky.")] [Tooltip("How hard the planted tread resists the chassis sliding at its position.")]
public float pivotAnchorStrength = 2500f; public float pivotAnchorStrength = 2500f;
[Tooltip("Extra angular damping (torque opposing spin) while planting. Keeps the pivot from becoming a wild spin — the planted foot should feel 'stuck', not greasy.")] [Tooltip("Extra angular damping (torque opposing spin) while planting.")]
public float pivotAngularResistance = 400f; public float pivotAngularResistance = 400f;
// ---- (c) PASSIVE LEAN tuning --------------------------------------------- // ---- (c) PASSIVE LEAN tuning ---------------------------------------------
[Header("(c) Passive lean — anti-tip ballast")] [Header("(c) Passive lean — anti-tip ballast")]
[Tooltip("Ballast torque (N·m) at full sideways stick, applied along the drive (forward) axis to counter-roll the tall bot. Tune vs how tippy the chassis is.")] [Tooltip("Ballast torque (N·m) at full sideways stick, applied along the drive (forward) axis.")]
public float leanTorque = 800f; public float leanTorque = 800f;
[Tooltip("Below this sideways magnitude, no lean torque — resting stick = no ballast.")] [Tooltip("Below this sideways magnitude, no lean torque.")]
[Range(0f, 0.5f)] [Range(0f, 0.5f)]
public float leanDeadzone = 0.08f; public float leanDeadzone = 0.08f;
// The seat this tread maps to, resolved once from `side`.
SeatManager.Seat MySeat => side == Side.Left ? SeatManager.Seat.Left : SeatManager.Seat.Right;
// Source of truth for who pilots which tread. Found on a parent (the chassis).
SeatManager _seats;
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// HOST-SIDE cached input. These are written ONLY by the RPC (which only runs // HOST-SIDE cached input. Written ONLY by the (validated) RPC on the server and
// on the server) and read ONLY in FixedUpdate (also gated to server). We never // read ONLY in FixedUpdate (also server-gated). No sync primitive needed — the
// touch them on a non-owning client, so no sync primitive is needed — the
// authoritative simulation is entirely host-local. // authoritative simulation is entirely host-local.
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
float _throttle; // 0..1, already deadzoned/clamped by the sender float _throttle; // 0..1
bool _pivotHeld; // is the pilot planting this tread right now? bool _pivotHeld;
float _lean; // -1..1 sideways ballast request float _lean; // -1..1
void Awake() void Awake()
{ {
// Convenience: if nobody wired the chassis in the inspector, grab the
// Rigidbody off a parent. All treads should end up pointing at the SAME
// chassis Rigidbody — that shared reference is what makes it one bot.
if (chassis == null) if (chassis == null)
chassis = GetComponentInParent<Rigidbody>(); chassis = GetComponentInParent<Rigidbody>();
_seats = GetComponentInParent<SeatManager>();
} }
// ========================================================================== // ==========================================================================
// CLIENT: read local input, ship it to the host. Nothing is applied locally. // CLIENT: if the LOCAL player holds this tread's seat, read input and ship it
// Runs every frame on the owning pilot only. // to the host. Nothing is applied locally. Seat ownership — not NetworkObject
// ownership — is the gate, so each client self-selects the tread it pilots.
// ========================================================================== // ==========================================================================
void Update() void Update()
{ {
if (!IsOwner) return; if (!IsSpawned || _seats == null) return;
ulong me = NetworkManager.Singleton.LocalClientId;
if (_seats.PilotOf(MySeat) != me) return; // I don't drive this tread
float throttle = ReadThrottle(); // 0..1 float throttle = ReadThrottle(); // 0..1
bool pivot = ReadPivotHeld(); bool pivot = ReadPivotHeld();
float lean = ReadLean(); // -1..1 float lean = ReadLean(); // -1..1
// One tiny packet per frame to the host. The host simulates; we watch the
// replicated chassis move. That round-trip "lag between my stick and the
// bot lurching" is itself part of the janky feel we're testing.
SubmitTreadInputRpc(throttle, pivot, lean); SubmitTreadInputRpc(throttle, pivot, lean);
} }
/// <summary> /// <summary>
/// Owning client → host. Named *Rpc + [Rpc(SendTo.Server)] per NGO 2.x. /// Owning pilot's client → host. RequireOwnership is FALSE (the bot's
/// RequireOwnership stays true (default): only the pilot who owns this tread /// NetworkObject is owned by the server, not the pilot) — instead the host
/// may drive it. The host just caches; forces are applied in FixedUpdate. /// re-validates the SEAT so a client can only drive the tread it actually
/// pilots. The host just caches; forces are applied in FixedUpdate.
/// </summary> /// </summary>
[Rpc(SendTo.Server)] [Rpc(SendTo.Server, RequireOwnership = false)]
void SubmitTreadInputRpc(float throttle, bool pivotHeld, float lean, RpcParams _ = default) void SubmitTreadInputRpc(float throttle, bool pivotHeld, float lean, RpcParams rpcParams = default)
{ {
// Anti-spoof: only honor input from the client that currently holds this seat.
if (_seats == null || !_seats.OwnsSeat(MySeat, rpcParams.Receive.SenderClientId))
return;
_throttle = Mathf.Clamp01(throttle); _throttle = Mathf.Clamp01(throttle);
_pivotHeld = pivotHeld; _pivotHeld = pivotHeld;
_lean = Mathf.Clamp(lean, -1f, 1f); _lean = Mathf.Clamp(lean, -1f, 1f);
@ -135,47 +141,38 @@ namespace Jankenbots.Prototype
// ========================================================================== // ==========================================================================
// HOST ONLY: turn the cached input into literal forces on the SHARED chassis. // HOST ONLY: turn the cached input into literal forces on the SHARED chassis.
// This is the entire physics of the bot. Two TreadParts running this in the // Two TreadParts running this in the same FixedUpdate, pushing at their two
// same FixedUpdate, pushing at their two different world positions, ARE the // different world positions, ARE the differential drive.
// differential drive.
// ========================================================================== // ==========================================================================
void FixedUpdate() void FixedUpdate()
{ {
if (!IsServer) return; // authority guard — clients never simulate if (!IsServer) return; // authority guard — clients never simulate
if (chassis == null) return; if (chassis == null) return;
// Un-piloted tread goes limp: no pilot → no cached force. (Also clears any
// stale input the instant a pilot leaves, so the bot doesn't coast on it.)
if (_seats == null || _seats.PilotOf(MySeat) == SeatManager.NoPilot)
{
_throttle = 0f; _pivotHeld = false; _lean = 0f;
return;
}
Vector3 treadPos = transform.position; // where THIS tread pushes from Vector3 treadPos = transform.position; // where THIS tread pushes from
if (_pivotHeld) if (_pivotHeld)
{ {
// ---- (b) LOCK-PIVOT -------------------------------------------- // ---- (b) LOCK-PIVOT --------------------------------------------
// The pilot has planted this tread. We do NOT drive with it; instead // Plant this tread: cancel linear slip at its position and bleed spin
// we make the chassis behave as if it's pinned at this tread's // so the OTHER tread's thrust swings the bot about this point.
// position, so the OTHER tread's thrust swings the whole bot around
// this point like a pivoting foot.
//
// 1) Cancel the sideways/linear slip AT the tread position by pushing
// back against the local velocity there. GetPointVelocity gives the
// chassis' velocity at this world point (includes rotation), so
// opposing it plants the point in space.
Vector3 pointVel = chassis.GetPointVelocity(treadPos); Vector3 pointVel = chassis.GetPointVelocity(treadPos);
chassis.AddForceAtPosition(-pointVel * pivotAnchorStrength, treadPos, ForceMode.Force); chassis.AddForceAtPosition(-pointVel * pivotAnchorStrength, treadPos, ForceMode.Force);
// 2) Bleed off raw spin so the pivot feels 'stuck', not greasy. This
// is a soft angular brake, NOT a hard lock — we still want jank.
chassis.AddTorque(-chassis.angularVelocity * pivotAngularResistance, ForceMode.Force); chassis.AddTorque(-chassis.angularVelocity * pivotAngularResistance, ForceMode.Force);
return; // a planted tread is an anchor, not a motor
// NOTE: no throttle drive while planting — a planted tread is an
// anchor, not a motor. (Lean is also skipped: you're busy pivoting.)
return;
} }
// ---- (a) SNAP-TO-CRUISE THROTTLE ----------------------------------- // ---- (a) SNAP-TO-CRUISE THROTTLE -----------------------------------
// throttle is 0..1. Full stick == full cruiseForce (the "snap to cruise" // Full stick == full cruiseForce; partial scales down. Applied AT the
// commitment); partial stick scales it down so feathering is possible but // tread's world position along its own forward → left/right offset =
// not the point. Push along the tread's own forward so a mis-aligned /
// knocked-askew tread pushes the bot in a wonky direction — jank on
// purpose. Applied AT the tread's world position → left+right offset =
// differential drive (asymmetric throttle turns the bot). // differential drive (asymmetric throttle turns the bot).
if (_throttle > throttleDeadzone) if (_throttle > throttleDeadzone)
{ {
@ -184,9 +181,6 @@ namespace Jankenbots.Prototype
} }
// ---- (c) PASSIVE LEAN — anti-tip ballast --------------------------- // ---- (c) PASSIVE LEAN — anti-tip ballast ---------------------------
// Sideways stick shovels ballast torque along the drive (forward) axis to
// counter-roll the top-heavy bot. It's a constant balancing chore the
// pilots share; it does NOT steer (that's the throttle differential).
if (Mathf.Abs(_lean) > leanDeadzone) if (Mathf.Abs(_lean) > leanDeadzone)
{ {
chassis.AddTorque(transform.forward * (_lean * leanTorque), ForceMode.Force); chassis.AddTorque(transform.forward * (_lean * leanTorque), ForceMode.Force);
@ -194,57 +188,38 @@ namespace Jankenbots.Prototype
} }
// ========================================================================== // ==========================================================================
// INPUT READERS — placeholder wiring for M1. Swap for real Input System // INPUT READERS — new Input System (project is Input-System-New-only, so legacy
// actions once seats are assigned; kept trivial so the physics is testable // UnityEngine.Input would throw). Same keys for both seats; the SEAT gate in
// immediately. Only ever called on the owning client (inside Update's guard). // Update() decides which tread a given client actually drives. Only called for
// the tread the local player pilots. Swap for per-seat gamepad actions later.
// Throttle = W / Up · Pivot = Left Shift · Lean = A/D or Left/Right
// ========================================================================== // ==========================================================================
/// <summary>0..1 throttle. Vertical axis, forward only (no reverse in v0.1).</summary> /// <summary>0..1 throttle. Forward only (no reverse in v0.1).</summary>
float ReadThrottle() float ReadThrottle()
{ {
// Forward-only: negative stick = 0 throttle (reverse is a later feel test). var kb = Keyboard.current;
float v = Mathf.Max(0f, Input.GetAxisRaw("Vertical")); if (kb == null) return 0f;
float v = (kb.wKey.isPressed || kb.upArrowKey.isPressed) ? 1f : 0f;
return v < throttleDeadzone ? 0f : v; return v < throttleDeadzone ? 0f : v;
} }
/// <summary>Is the plant-pivot button held?</summary> /// <summary>Is the plant-pivot button held?</summary>
bool ReadPivotHeld() bool ReadPivotHeld()
{ {
// Placeholder: left shift = plant. Real build: per-seat gamepad button. var kb = Keyboard.current;
return Input.GetKey(KeyCode.LeftShift); return kb != null && (kb.leftShiftKey.isPressed || kb.rightShiftKey.isPressed);
} }
/// <summary>-1..1 sideways ballast request.</summary> /// <summary>-1..1 sideways ballast request.</summary>
float ReadLean() float ReadLean()
{ {
float h = Input.GetAxisRaw("Horizontal"); var kb = Keyboard.current;
if (kb == null) return 0f;
float h = 0f;
if (kb.aKey.isPressed || kb.leftArrowKey.isPressed) h -= 1f;
if (kb.dKey.isPressed || kb.rightArrowKey.isPressed) h += 1f;
return Mathf.Abs(h) < leanDeadzone ? 0f : h; return Mathf.Abs(h) < leanDeadzone ? 0f : h;
} }
// ==========================================================================
// Seat lifecycle. The bot spawns owned by the server; the seat manager grants
// a tread to a pilot via NetworkObject.ChangeOwnership(clientId) (server-only,
// see cheat-sheet §8). These hooks just log so we can see claims land while
// testing with friends, and clear stale input if a pilot leaves.
// ==========================================================================
public override void OnGainedOwnership()
{
base.OnGainedOwnership();
if (IsOwner)
Debug.Log($"[TreadPart] {side} tread claimed by local pilot (client {OwnerClientId}).");
}
public override void OnLostOwnership()
{
base.OnLostOwnership();
// On the host, wipe cached input so an un-piloted tread goes limp instead
// of coasting on the last pilot's stick.
if (IsServer)
{
_throttle = 0f;
_pivotHeld = false;
_lean = 0f;
}
}
} }
} }