浏览代码

Merging from Dev to fix double-main head issue.

/main
当前提交
8cbba883
共有 127 个文件被更改,包括 6015 次插入5290 次删除
  1. 3
      .gitignore
  2. 4
      Assets/Prefabs/UI/JoinContent.prefab
  3. 7
      Assets/Prefabs/UI/LobbyButtonUI.prefab
  4. 16
      Assets/Prefabs/UI/LobbyCodeCanvas.prefab
  5. 22
      Assets/Prefabs/UI/LobbyGameCanvas.prefab
  6. 42
      Assets/Prefabs/UI/LobbyUserList.prefab
  7. 4
      Assets/Prefabs/UI/PlayerInteractionPanel.prefab
  8. 17
      Assets/Prefabs/UI/RelayCodeCanvas.prefab
  9. 283
      Assets/Scenes/mainScene.unity
  10. 2
      Assets/Scripts/Auth/Identity.cs
  11. 2
      Assets/Scripts/Auth/SubIdentity_Authentication.cs
  12. 159
      Assets/Scripts/Entities/GameStateManager.cs
  13. 6
      Assets/Scripts/Entities/LobbyServiceData.cs
  14. 14
      Assets/Scripts/Entities/LobbyUser.cs
  15. 542
      Assets/Scripts/Entities/LocalLobby.cs
  16. 24
      Assets/Scripts/Tests/Editor/LobbyTests.cs
  17. 6
      Assets/Scripts/Tests/Editor/ObserverTests.cs
  18. 2
      Assets/Scripts/Tests/Editor/Tests.Editor.asmdef
  19. 2
      Assets/Scripts/Tests/PlayMode/Tests.Play.asmdef
  20. 3
      Assets/Scripts/UI/BackButtonUI.cs
  21. 7
      Assets/Scripts/UI/CountdownUI.cs
  22. 7
      Assets/Scripts/UI/CreateMenuUI.cs
  23. 3
      Assets/Scripts/UI/EmoteButtonUI.cs
  24. 5
      Assets/Scripts/UI/EndGameButtonUI.cs
  25. 3
      Assets/Scripts/UI/ExitButtonUI.cs
  26. 3
      Assets/Scripts/UI/GameStateVisibilityUI.cs
  27. 61
      Assets/Scripts/UI/JoinMenuUI.cs
  28. 15
      Assets/Scripts/UI/LobbyButtonUI.cs
  29. 2
      Assets/Scripts/UI/MainMenuUI.cs
  30. 2
      Assets/Scripts/UI/NameChangeUI.cs
  31. 5
      Assets/Scripts/UI/ObserverPanel.cs
  32. 3
      Assets/Scripts/UI/PlayerNameUI.cs
  33. 3
      Assets/Scripts/UI/ReadyCheckUI.cs
  34. 3
      Assets/Scripts/UI/SpinnerUI.cs
  35. 3
      Assets/Scripts/UI/UIPanelBase.cs
  36. 5
      Assets/Scripts/UI/UserStateVisibilityUI.cs
  37. 42
      Assets/Scripts/UI/JoinCreateLobbyUI.cs
  38. 4
      Assets/Scripts/UI/InLobbyUserUI.cs
  39. 2
      Assets/Scripts/LobbyRelaySample.asmdef
  40. 61
      README.md
  41. 999
      ~Documentation/Images/dashboard1_beta.png
  42. 999
      ~Documentation/Images/services1.PNG
  43. 999
      ~Documentation/Images/services2.PNG
  44. 251
      ~Documentation/Images/tutorial_1_lobbyList.png
  45. 353
      ~Documentation/Images/tutorial_2_createMenu.png
  46. 565
      ~Documentation/Images/tutorial_3_HostGame.png
  47. 352
      ~Documentation/Images/tutorial_4_newLobby.png
  48. 947
      ~Documentation/Images/tutorial_5_editorCow.png
  49. 803
      ~Documentation/Images/tutorial_6_countDown.png
  50. 971
      ~Documentation/Images/tutorial_7_ingame.png
  51. 200
      Assets/Scripts/Infrastructure/Locator.cs
  52. 126
      Assets/Scripts/Infrastructure/LogHandler.cs
  53. 176
      Assets/Scripts/Infrastructure/Messenger.cs
  54. 5
      Assets/Scripts/Auth/NameGenerator.cs
  55. 276
      Assets/Scripts/Infrastructure/UpdateSlow.cs
  56. 4
      Assets/Scripts/Entities/LocalLobbyObserver.cs
  57. 130
      Assets/Scripts/Tests/PlayMode/LobbyReadyCheckTests.cs
  58. 163
      Assets/Scripts/Tests/PlayMode/LobbyRoundtripTests.cs
  59. 33
      Assets/Scripts/UI/DisplayCodeUI.cs
  60. 60
      Assets/Scripts/UI/InLobbyUserList.cs
  61. 19
      Assets/Scripts/UI/LobbyNameUI.cs
  62. 19
      Assets/Scripts/UI/RelayAddressUI.cs
  63. 22
      Assets/Scripts/UI/ShowWhenLobbyStateUI.cs
  64. 15
      Assets/Scripts/UI/StartLobbyButtonUI.cs
  65. 108
      Assets/Scripts/Lobby/LobbyAPIInterface.cs
  66. 218
      Assets/Scripts/Lobby/LobbyAsyncRequests.cs
  67. 109
      Assets/Scripts/Lobby/LobbyContentHeartbeat.cs
  68. 23
      Assets/Scripts/Lobby/LobbyListHeartbeat.cs
  69. 63
      Assets/Scripts/Lobby/ReadyCheck.cs
  70. 89
      Assets/Scripts/Lobby/ToLocalLobby.cs
  71. 4
      Assets/Scripts/Entities/LobbyDataObserver.cs
  72. 67
      Assets/Scripts/Entities/LobbyReadyCheck.cs
  73. 131
      Assets/Scripts/Tests/PlayMode/ReadyCheckTests.cs
  74. 163
      Assets/Scripts/Tests/PlayMode/RoomsRoundtripTests.cs
  75. 11
      Assets/Scripts/UI/RelayCodeUI.cs.meta
  76. 5
      Assets/Scripts/UI/SerializedValueEvents.cs
  77. 11
      Assets/Scripts/UI/SerializedValueEvents.cs.meta
  78. 19
      Assets/Scripts/UI/LobbyStateVisibilityUI.cs
  79. 60
      Assets/Scripts/UI/LobbyUsersUI.cs
  80. 27
      Assets/Scripts/UI/RelayCodeUI.cs
  81. 26
      Assets/Scripts/UI/RoomCodeUI.cs
  82. 16
      Assets/Scripts/UI/ServerAddressUI.cs
  83. 16
      Assets/Scripts/UI/ServerNameUI.cs
  84. 12
      Assets/Scripts/UI/StartLobbyButton.cs
  85. 8
      Assets/Scripts/Utilities.meta
  86. 11
      Assets/TempDeleteAllRooms.cs.meta
  87. 33
      Assets/TempDeleteAllRooms.cs
  88. 167
      ProjectSettings/SceneTemplateSettings.json
  89. 16
      ProjectSettings/BurstAotSettings_StandaloneWindows.json
  90. 6
      ProjectSettings/CommonBurstAotSettings.json
  91. 0
      /Assets/Scripts/Entities/LocalLobbyObserver.cs.meta
  92. 0
      /Assets/Scripts/Entities/LocalLobby.cs.meta
  93. 0
      /Assets/Scripts/Entities/LocalLobby.cs
  94. 0
      /Assets/Scripts/Tests/PlayMode/LobbyRoundtripTests.cs.meta
  95. 0
      /Assets/Scripts/Tests/PlayMode/LobbyReadyCheckTests.cs.meta
  96. 0
      /Assets/Scripts/UI/StartLobbyButtonUI.cs.meta
  97. 0
      /Assets/Scripts/UI/ShowWhenLobbyStateUI.cs.meta

3
.gitignore


.idea
.idea/
.UserSettings
.UserSettings/

4
Assets/Prefabs/UI/JoinContent.prefab


m_CallState: 2
showing: 1
m_LobbyButtonPrefab: {fileID: 7018369548608736188, guid: f6d35a456ba76a24587dce83bd088b7d, type: 3}
m_RoomCodeField: {fileID: 8659642538454988273}
m_RoomButtonParent: {fileID: 7824921818678239159}
m_LobbyCodeField: {fileID: 8659642538454988273}
m_LobbyButtonParent: {fileID: 7824921818678239159}
--- !u!114 &7550446569341709048
MonoBehaviour:
m_ObjectHideFlags: 0

7
Assets/Prefabs/UI/LobbyButtonUI.prefab


m_PersistentCalls:
m_Calls:
- m_Target: {fileID: 7018369548608736188}
m_TargetAssemblyTypeName: LobbyRooms.UI.LobbyButtonUI, LobbyRooms
m_MethodName: OnRoomUpdated
m_Mode: 0
m_TargetAssemblyTypeName: LobbyRelaySample.UI.LobbyButtonUI, LobbyRooms
m_MethodName: OnLobbyClicked
m_Mode: 1
m_Arguments:
m_ObjectArgument: {fileID: 0}
m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine

m_BoolArgument: 0
m_CallState: 2
observeOnStart: 1
--- !u!114 &1059336587790472163
MonoBehaviour:
m_ObjectHideFlags: 0

16
Assets/Prefabs/UI/LobbyCodeCanvas.prefab


m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 22
m_fontSizeBase: 22
m_fontSize: 18
m_fontSizeBase: 18
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18

m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_SizeDelta: {x: 200, y: 25}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1118541987231860831
CanvasRenderer:

m_Script: {fileID: 11500000, guid: a6e005db2e7b3d94d9409975660cf97c, type: 3}
m_Name:
m_EditorClassIdentifier:
m_onVisibilityChange:
m_PersistentCalls:
m_Calls: []
showing: 0
roomCodeText: {fileID: 5578852939709204548}
--- !u!114 &699060394989383769

m_StringArgument:
m_BoolArgument: 0
m_CallState: 2
observeOnStart: 1
--- !u!1 &2798863108443093305
GameObject:
m_ObjectHideFlags: 0

m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 20.55
m_fontSize: 14
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontSizeMin: 14
m_fontSizeMax: 22
m_fontStyle: 2
m_HorizontalAlignment: 2
m_VerticalAlignment: 512

22
Assets/Prefabs/UI/LobbyGameCanvas.prefab


m_PersistentCalls:
m_Calls: []
showing: 0
m_ServerNameText: {fileID: 7322885418547999459}
m_lobbyNameText: {fileID: 7322885418547999459}
--- !u!225 &6749879306276389991
CanvasGroup:
m_ObjectHideFlags: 0

- component: {fileID: 2725000139462485941}
- component: {fileID: 7322885418547999459}
m_Layer: 5
m_Name: ServerText
m_Name: LobbyNameText
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0

- component: {fileID: 279783410280127446}
- component: {fileID: 8630015524497407890}
m_Layer: 5
m_Name: ServerIPPanel
m_Name: RelayIPPanel
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0

propertyPath: m_fontSize
value: 26.15
objectReference: {fileID: 0}
- target: {fileID: 840905996306701940, guid: e269788e17cbca145bf78e8971aeb223, type: 3}
propertyPath: m_fontSize
value: 26.15
objectReference: {fileID: 0}
- target: {fileID: 1079919869245620301, guid: e269788e17cbca145bf78e8971aeb223, type: 3}
propertyPath: m_AnchorMax.y
value: 0

- target: {fileID: 2762366222576510419, guid: e269788e17cbca145bf78e8971aeb223, type: 3}
propertyPath: m_AnchoredPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3246194187207366366, guid: e269788e17cbca145bf78e8971aeb223, type: 3}
propertyPath: m_fontSize
value: 26.15
objectReference: {fileID: 0}
- target: {fileID: 3253464371495375142, guid: e269788e17cbca145bf78e8971aeb223, type: 3}
propertyPath: m_AnchorMax.y

propertyPath: m_LocalEulerAnglesHint.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 4917538085660885383, guid: e269788e17cbca145bf78e8971aeb223, type: 3}
propertyPath: m_fontSize
value: 26.15
objectReference: {fileID: 0}
- target: {fileID: 5186258928042496532, guid: e269788e17cbca145bf78e8971aeb223, type: 3}
propertyPath: m_fontSize
value: 26.15

- target: {fileID: 7620090743157897760, guid: e269788e17cbca145bf78e8971aeb223, type: 3}
propertyPath: m_AnchoredPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7824963406237393945, guid: e269788e17cbca145bf78e8971aeb223, type: 3}
propertyPath: m_fontSize
value: 26.15
objectReference: {fileID: 0}
- target: {fileID: 8020923114782963594, guid: e269788e17cbca145bf78e8971aeb223, type: 3}
propertyPath: m_fontSize

42
Assets/Prefabs/UI/LobbyUserList.prefab


m_FallbackScreenDPI: 96
m_DefaultSpriteDPI: 96
m_DynamicPixelsPerUnit: 1
m_PresetInfoIsWorld: 1
m_PresetInfoIsWorld: 0
--- !u!114 &4463750083940306577
MonoBehaviour:
m_ObjectHideFlags: 0

m_PersistentCalls:
m_Calls: []
showing: 0
m_PlayerCardSlots:
m_UserUIObjects:
- {fileID: 8208727230702623701}
- {fileID: 4503217775887893542}
- {fileID: 1646035575854440888}

m_Modifications:
- target: {fileID: 1767503274657767312, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_Name
value: UserCardPanel_4
value: UserUI_4
objectReference: {fileID: 0}
- target: {fileID: 2889154384621986752, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_AnchorMax.y

objectReference: {fileID: 0}
- target: {fileID: 3540056819003737500, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_fontSize
value: 26.15
value: 58
value: 26.15
value: 72
objectReference: {fileID: 0}
- target: {fileID: 5235189765028238254, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: showing

objectReference: {fileID: 0}
m_RemovedComponents: []
m_SourcePrefab: {fileID: 100100000, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
--- !u!224 &3685075291402800769 stripped
RectTransform:
m_CorrespondingSourceObject: {fileID: 3229036008637484624, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
m_PrefabInstance: {fileID: 2300164376451760337}
m_PrefabAsset: {fileID: 0}
--- !u!114 &6290668908104815487 stripped
MonoBehaviour:
m_CorrespondingSourceObject: {fileID: 5235189765028238254, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}

m_Script: {fileID: 11500000, guid: 9557a5d232068a149987bc0753800f26, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!224 &3685075291402800769 stripped
RectTransform:
m_CorrespondingSourceObject: {fileID: 3229036008637484624, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
m_PrefabInstance: {fileID: 2300164376451760337}
m_PrefabAsset: {fileID: 0}
--- !u!1001 &4128781293466929275
PrefabInstance:
m_ObjectHideFlags: 0

m_Modifications:
- target: {fileID: 1767503274657767312, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_Name
value: UserCardPanel_1
value: UserUI_1
objectReference: {fileID: 0}
- target: {fileID: 2889154384621986752, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_AnchorMax.y

propertyPath: m_LocalEulerAnglesHint.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3540056819003737500, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_fontSize
value: 58
objectReference: {fileID: 0}
- target: {fileID: 3667934756810823183, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_fontSize
value: 72
objectReference: {fileID: 0}
- target: {fileID: 5804120253616419419, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_AnchorMax.y
value: 0

m_Modifications:
- target: {fileID: 1767503274657767312, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_Name
value: UserCardPanel_3
value: UserUI_3
objectReference: {fileID: 0}
- target: {fileID: 2889154384621986752, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_AnchorMax.y

objectReference: {fileID: 0}
- target: {fileID: 3540056819003737500, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_fontSize
value: 26.15
value: 58
value: 26.15
value: 72
objectReference: {fileID: 0}
- target: {fileID: 5235189765028238254, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: showing

m_Modifications:
- target: {fileID: 1767503274657767312, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_Name
value: UserCardPanel_2
value: UserUI_2
objectReference: {fileID: 0}
- target: {fileID: 2889154384621986752, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_AnchorMax.y

objectReference: {fileID: 0}
- target: {fileID: 3540056819003737500, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: m_fontSize
value: 26.15
value: 58
value: 26.15
value: 72
objectReference: {fileID: 0}
- target: {fileID: 5235189765028238254, guid: 9c09208dc6d58f54aabb57c12071b660, type: 3}
propertyPath: showing

4
Assets/Prefabs/UI/PlayerInteractionPanel.prefab


m_PersistentCalls:
m_Calls:
- m_Target: {fileID: 900710893788416922}
m_TargetAssemblyTypeName: LobbyRooms.UI.GameStateButtonUI, Assembly-CSharp
m_MethodName: EndServer
m_TargetAssemblyTypeName: LobbyRelaySample.UI.EndGameButtonUI, LobbyRelaySample
m_MethodName: EndGame
m_Mode: 1
m_Arguments:
m_ObjectArgument: {fileID: 0}

17
Assets/Prefabs/UI/RelayCodeCanvas.prefab


m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 20.55
m_fontSize: 72
m_fontSizeBase: 45
m_fontWeight: 400
m_enableAutoSizing: 1

m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 18.2
m_fontSize: 68.3
m_fontSizeBase: 36
m_fontWeight: 400
m_enableAutoSizing: 1

- component: {fileID: 4102997489641105917}
- component: {fileID: 4102997489641105913}
- component: {fileID: 7676491730539518990}
- component: {fileID: 2381432589029884226}
- component: {fileID: 3340928240658051873}
- component: {fileID: 4523467532116611583}
m_Layer: 5
m_Name: RelayCodeCanvas

m_Interactable: 0
m_BlocksRaycasts: 0
m_IgnoreParentGroups: 0
--- !u!114 &2381432589029884226
--- !u!114 &3340928240658051873
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}

m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a70bc1b6f999ee43974bbbf991a0b6a, type: 3}
m_Script: {fileID: 11500000, guid: a6e005db2e7b3d94d9409975660cf97c, type: 3}
m_Name:
m_EditorClassIdentifier:
m_onVisibilityChange:

relayCodeText: {fileID: 8798075752901962210}
m_outputText: {fileID: 8798075752901962210}
m_codeType: 1
--- !u!114 &4523467532116611583
MonoBehaviour:
m_ObjectHideFlags: 0

OnObservedUpdated:
m_PersistentCalls:
m_Calls:
- m_Target: {fileID: 2381432589029884226}
m_TargetAssemblyTypeName: LobbyRooms.UI.RelayCodeUI, LobbyRooms
- m_Target: {fileID: 3340928240658051873}
m_TargetAssemblyTypeName: LobbyRelaySample.UI.DisplayCodeUI, LobbyRelaySample
m_MethodName: ObservedUpdated
m_Mode: 0
m_Arguments:

283
Assets/Scenes/mainScene.unity


m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!224 &462074380 stripped
RectTransform:
m_CorrespondingSourceObject: {fileID: 2637199315671523625, guid: f1d618bdc6f1813449d428126e640aa5, type: 3}
m_PrefabInstance: {fileID: 2637199315837045693}
m_PrefabAsset: {fileID: 0}
--- !u!114 &648562208 stripped
MonoBehaviour:
m_CorrespondingSourceObject: {fileID: 6939937855246394599, guid: f1d618bdc6f1813449d428126e640aa5, type: 3}

m_Script: {fileID: 11500000, guid: 70dfc2fde0a9ef04eaff29a138f0bf45, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!1 &1790515943
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1790515944}
- component: {fileID: 1790515946}
- component: {fileID: 1790515945}
m_Layer: 5
m_Name: Text
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1790515944
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1790515943}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1839563668}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1790515945
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1790515943}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 10102, guid: 0000000000000000e000000000000000, type: 0}
m_FontSize: 14
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 10
m_MaxSize: 40
m_Alignment: 4
m_AlignByGeometry: 0
m_RichText: 1
m_HorizontalOverflow: 0
m_VerticalOverflow: 0
m_LineSpacing: 1
m_Text: Delete all my rooms
--- !u!222 &1790515946
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1790515943}
m_CullTransparentMesh: 1
--- !u!1 &1839563667
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1839563668}
- component: {fileID: 1839563672}
- component: {fileID: 1839563671}
- component: {fileID: 1839563670}
- component: {fileID: 1839563669}
m_Layer: 5
m_Name: DeleteAllMine
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1839563668
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1839563667}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1790515944}
m_Father: {fileID: 462074380}
m_RootOrder: 4
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 1}
m_AnchorMax: {x: 0.5, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 160, y: 30}
m_Pivot: {x: 0.5, y: 1}
--- !u!114 &1839563669
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1839563667}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0e7b1d59375bdf94795f339fd9395761, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!114 &1839563670
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1839563667}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 1839563671}
m_OnClick:
m_PersistentCalls:
m_Calls:
- m_Target: {fileID: 1839563669}
m_TargetAssemblyTypeName: TempDeleteAllRooms, Assembly-CSharp
m_MethodName: OnButton
m_Mode: 1
m_Arguments:
m_ObjectArgument: {fileID: 0}
m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine
m_IntArgument: 0
m_FloatArgument: 0
m_StringArgument:
m_BoolArgument: 0
m_CallState: 2
--- !u!114 &1839563671
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1839563667}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!222 &1839563672
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1839563667}
m_CullTransparentMesh: 1
--- !u!114 &1886099429 stripped
MonoBehaviour:
m_CorrespondingSourceObject: {fileID: 3845984648666374778, guid: f1d618bdc6f1813449d428126e640aa5, type: 3}

m_Script: {fileID: 11500000, guid: 51373dc3c6ac79b4f8e36ac7c4419205, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!114 &2637199315837045697 stripped
MonoBehaviour:
m_CorrespondingSourceObject: {fileID: 3903006825828350709, guid: f1d618bdc6f1813449d428126e640aa5, type: 3}
m_PrefabInstance: {fileID: 2637199315837045693}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 70dfc2fde0a9ef04eaff29a138f0bf45, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!114 &2637199315837045698 stripped
MonoBehaviour:
m_CorrespondingSourceObject: {fileID: 618971913928185130, guid: f1d618bdc6f1813449d428126e640aa5, type: 3}

- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_LocalUserObservers.Array.size
value: 3
objectReference: {fileID: 0}
- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_LocalLobbyObservers.Array.size
value: 9
objectReference: {fileID: 0}
- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_GameStateObservers.Array.data[0]

propertyPath: m_LocalUserObservers.Array.data[3]
value:
objectReference: {fileID: 0}
- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_LocalLobbyObservers.Array.data[0]
value:
objectReference: {fileID: 1886099429}
- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_LocalLobbyObservers.Array.data[1]
value:
objectReference: {fileID: 648562208}
- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_LocalLobbyObservers.Array.data[2]
value:
objectReference: {fileID: 1412109061}
- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_LocalLobbyObservers.Array.data[3]
value:
objectReference: {fileID: 297599733}
- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_LocalLobbyObservers.Array.data[4]
value:
objectReference: {fileID: 2637199315837045697}
- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_LocalLobbyObservers.Array.data[5]
value:
objectReference: {fileID: 2130620598}
- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_LocalLobbyObservers.Array.data[6]
value:
objectReference: {fileID: 2074106027}
- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_LocalLobbyObservers.Array.data[7]
value:
objectReference: {fileID: 309485569}
- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_LocalLobbyObservers.Array.data[8]
value:
objectReference: {fileID: 2126854580}
- target: {fileID: 7716713811812636910, guid: f80fc24bab3dcda459a2669321e2e5a4, type: 3}
propertyPath: m_LobbyServerObservers.Array.data[0]
value:

2
Assets/Scripts/Auth/Identity.cs


/// <summary>
/// Our internal representation of a player, wrapping the data required for interfacing with the identities of that player in the services.
/// One will be created for the local player, as well as for each other member of the room.
/// One will be created for the local player, as well as for each other member of the lobby.
/// </summary>
public class Identity : IIdentity, IDisposable
{

2
Assets/Scripts/Auth/SubIdentity_Authentication.cs


AuthenticationService.Instance.SignedOut += OnSignInChange;
if (!AuthenticationService.Instance.IsSignedIn)
await AuthenticationService.Instance.SignInAnonymouslyAsync(); // Note: We don't want to sign out later, since that changes the UAS anonymous token, which would prevent the player from exiting rooms they're already in.
await AuthenticationService.Instance.SignInAnonymouslyAsync(); // Note: We don't want to sign out later, since that changes the UAS anonymous token, which would prevent the player from exiting lobbies they're already in.
onSigninComplete?.Invoke();
}

159
Assets/Scripts/Entities/GameStateManager.cs


[SerializeField]
List<LocalGameStateObserver> m_GameStateObservers = new List<LocalGameStateObserver>();
[SerializeField]
List<LobbyDataObserver> m_LobbyDataObservers = new List<LobbyDataObserver>();
List<LocalLobbyObserver> m_LocalLobbyObservers = new List<LocalLobbyObserver>();
private RoomsContentHeartbeat m_roomsContentHeartbeat = new RoomsContentHeartbeat();
private LobbyContentHeartbeat m_lobbyContentHeartbeat = new LobbyContentHeartbeat();
LobbyData m_lobbyData;
LocalLobby m_localLobby;
LobbyReadyCheck m_LobbyReadyCheck;
ReadyCheck m_ReadyCheck;
public void Awake()
{

var unused = Locator.Get;
#pragma warning restore IDE0059 // Unnecessary assignment of a value
Locator.Get.Provide(new Auth.Identity(OnAuthSignIn));
m_LobbyReadyCheck = new LobbyReadyCheck(null, 7);
m_ReadyCheck = new ReadyCheck(7);
Application.wantsToQuit += OnWantToQuit;
}

{
m_localUser.DisplayName = (string)msg;
}
else if (type == MessageType.CreateRoomRequest)
else if (type == MessageType.CreateLobbyRequest)
var createRoomData = (LobbyData)msg;
RoomsQuery.Instance.CreateRoomAsync(createRoomData.LobbyName, createRoomData.MaxPlayerCount, createRoomData.Private, (r) =>
var createLobbyData = (LocalLobby)msg;
LobbyAsyncRequests.Instance.CreateLobbyAsync(createLobbyData.LobbyName, createLobbyData.MaxPlayerCount, createLobbyData.Private, (r) =>
Lobby.ToLobbyData.Convert(r, m_lobbyData, m_localUser);
OnCreatedRoom();
Lobby.ToLocalLobby.Convert(r, m_localLobby, m_localUser);
OnCreatedLobby();
else if (type == MessageType.JoinRoomRequest)
else if (type == MessageType.JoinLobbyRequest)
LobbyInfo roomData = (LobbyInfo)msg;
RoomsQuery.Instance.JoinRoomAsync(roomData.RoomID, roomData.RoomCode, (r) =>
LobbyInfo lobbyInfo = (LobbyInfo)msg;
LobbyAsyncRequests.Instance.JoinLobbyAsync(lobbyInfo.LobbyID, lobbyInfo.LobbyCode, (r) =>
Lobby.ToLobbyData.Convert(r, m_lobbyData, m_localUser);
OnJoinedRoom();
Lobby.ToLocalLobby.Convert(r, m_localLobby, m_localUser);
OnJoinedLobby();
else if (type == MessageType.QueryRooms)
else if (type == MessageType.QueryLobbies)
RoomsQuery.Instance.RetrieveRoomListAsync(
LobbyAsyncRequests.Instance.RetrieveLobbyListAsync(
OnRefreshed(Lobby.ToLobbyData.Convert(qr));
OnRefreshed(Lobby.ToLocalLobby.Convert(qr));
}, er =>
{
long errorLong = 0;

}
else if (type == MessageType.Client_EndReadyCountdownAt)
{
m_lobbyData.TargetEndTime = (DateTime)msg;
m_localLobby.TargetEndTime = (DateTime)msg;
BeginCountDown();
}
else if (type == MessageType.ToLobby)

void Start()
{
m_lobbyData = new LobbyData
m_localLobby = new LocalLobby
};
m_localUser = new LobbyUser();
m_localUser.DisplayName = "New Player";

m_GameStateObservers.Add(gameStateObs);
}
foreach (var lobbyData in FindObjectsOfType<LobbyDataObserver>())
foreach (var localLobby in FindObjectsOfType<LocalLobbyObserver>())
if (!lobbyData.observeOnStart)
if (!localLobby.observeOnStart)
if (!m_LobbyDataObservers.Contains(lobbyData))
m_LobbyDataObservers.Add(lobbyData);
if (!m_LocalLobbyObservers.Contains(localLobby))
m_LocalLobbyObservers.Add(localLobby);
}
foreach (var lobbyUserObs in FindObjectsOfType<LobbyUserObserver>())

if (m_GameStateObservers.Count < 4)
Debug.LogWarning($"Scene has less than the default expected Game State Observers, ensure all the observers in the scene that need to watch the gameState are registered in the LocalGameStateObservers List.");
if (m_LobbyDataObservers.Count < 8)
Debug.LogWarning($"Scene has less than the default expected Lobby Data Observers, ensure all the observers in the scene that need to watch the Local Lobby Data are registered in the LobbyDataObservers List.");
if (m_LocalLobbyObservers.Count < 8)
Debug.LogWarning($"Scene has less than the default expected Local Lobby Observers, ensure all the observers in the scene that need to watch the Local Lobby are registered in the LocalLobbyObservers List.");
if (m_LocalUserObservers.Count < 3)
Debug.LogWarning($"Scene has less than the default expected Local User Observers, ensure all the observers in the scene that need to watch the gameState are registered in the LocalUserObservers List.");

gameStateObs.BeginObserving(m_localGameState);
}
foreach (var lobbyObs in m_LobbyDataObservers)
foreach (var lobbyObs in m_LocalLobbyObservers)
{
if (lobbyObs == null)
{

lobbyObs.BeginObserving(m_lobbyData);
lobbyObs.BeginObserving(m_localLobby);
}
foreach (var userObs in m_LocalUserObservers)

void SetGameState(GameState state)
{
bool isLeavingRoom = (state == GameState.Menu || state == GameState.JoinMenu) && m_localGameState.State == GameState.Lobby;
bool isLeavingLobby = (state == GameState.Menu || state == GameState.JoinMenu) && m_localGameState.State == GameState.Lobby;
if (isLeavingRoom)
OnLeftRoom();
if (isLeavingLobby)
OnLeftLobby();
void OnRefreshed(IEnumerable<LobbyData> lobbies)
void OnRefreshed(IEnumerable<LocalLobby> lobbies)
var newLobbyDict = new Dictionary<string, LobbyData>();
var newLobbyDict = new Dictionary<string, LocalLobby>();
newLobbyDict.Add(lobby.RoomID, lobby);
newLobbyDict.Add(lobby.LobbyID, lobby);
}
m_lobbyServiceData.State = LobbyServiceState.Fetched;

m_lobbyServiceData.State = LobbyServiceState.Error;
}
void OnCreatedRoom()
void OnCreatedLobby()
OnJoinedRoom();
RelayInterface.AllocateAsync(m_lobbyData.MaxPlayerCount, OnGotRelayAllocation);
OnJoinedLobby();
}
void OnGotRelayAllocation(Allocation allocationID)

void OnGotRelayCode(string relayCode)
{
m_lobbyData.RelayCode = relayCode;
m_localLobby.RelayCode = relayCode;
void OnJoinedRoom()
void OnJoinedLobby()
RoomsQuery.Instance.BeginTracking(m_lobbyData.RoomID);
m_roomsContentHeartbeat.BeginTracking(m_lobbyData, m_localUser);
LobbyAsyncRequests.Instance.BeginTracking(m_localLobby.LobbyID);
m_lobbyContentHeartbeat.BeginTracking(m_localLobby, m_localUser);
RoomsQuery.Instance.UpdatePlayerDataAsync(displayNameData, null);
LobbyAsyncRequests.Instance.UpdatePlayerDataAsync(displayNameData, null);
void OnLeftRoom()
void OnLeftLobby()
RoomsQuery.Instance.LeaveRoomAsync(m_lobbyData.RoomID, ResetLobbyData);
m_roomsContentHeartbeat.EndTracking();
RoomsQuery.Instance.EndTracking();
LobbyAsyncRequests.Instance.LeaveLobbyAsync(m_localLobby.LobbyID, ResetLocalLobby);
m_lobbyContentHeartbeat.EndTracking();
LobbyAsyncRequests.Instance.EndTracking();
}
/// <summary>

void BeginCountDown()
{
// Only start the countdown once.
if (m_lobbyData.State == LobbyState.CountDown)
if (m_localLobby.State == LobbyState.CountDown)
m_lobbyData.CountDownTime = m_lobbyData.TargetEndTime.Subtract(DateTime.Now).Seconds;
m_lobbyData.State = LobbyState.CountDown;
// We want to do all the Relay Allocation calls in quick succession, as waiting too long
// (10s) will cause the Relay server to get cleaned up by the service
RelayInterface.AllocateAsync(m_localLobby.MaxPlayerCount, OnGotRelayAllocation);
m_localLobby.CountDownTime = m_localLobby.TargetEndTime.Subtract(DateTime.Now).Seconds;
m_localLobby.State = LobbyState.CountDown;
/// <summary>
/// This is currently a countdown to Connection, once we have our transport integrated, this will be a countdown to Game Start
/// </summary>
m_LobbyReadyCheck.EndCheckingForReady();
while (m_lobbyData.CountDownTime > 0)
m_ReadyCheck.EndCheckingForReady();
while (m_localLobby.CountDownTime > 0)
if (m_lobbyData.State != LobbyState.CountDown)
if (m_localLobby.State != LobbyState.CountDown)
m_lobbyData.CountDownTime = m_lobbyData.TargetEndTime.Subtract(DateTime.Now).Seconds;
m_localLobby.CountDownTime = m_localLobby.TargetEndTime.Subtract(DateTime.Now).Seconds;
m_lobbyData.State = LobbyState.InGame;
m_localLobby.State = LobbyState.InGame;
RelayInterface.JoinAsync(m_lobbyData.RelayCode, OnJoinedGame);
// TODO TRANSPORT: Move Relay Join to Pre-Countdown, and do connection and health checks before counting down for the game start.
RelayInterface.JoinAsync(m_localLobby.RelayCode, OnJoinedRelay);
void OnJoinedGame(JoinAllocation joinData)
/// <summary>
/// Non Hosts Connect to server Here
/// </summary>
void OnJoinedRelay(JoinAllocation joinData)
m_lobbyData.RelayServer = new ServerAddress(ip, port);
m_localLobby.RelayServer = new ServerAddress(ip, port);
m_lobbyData.State = LobbyState.Lobby;
m_lobbyData.CountDownTime = 0;
m_lobbyData.RelayServer = null;
m_localLobby.State = LobbyState.Lobby;
m_localLobby.CountDownTime = 0;
m_localLobby.RelayServer = null;
m_localLobby.RelayCode = null;
SetUserLobbyState();
}

m_localUser.UserStatus = UserStatus.Lobby;
if (m_localUser.IsHost)
m_LobbyReadyCheck.BeginCheckingForReady();
m_ReadyCheck.BeginCheckingForReady();
void ResetLobbyData()
void ResetLocalLobby()
m_lobbyData.CopyObserved(new LobbyInfo(), new Dictionary<string, LobbyUser>());
m_lobbyData.CountDownTime = 0;
m_lobbyData.RelayServer = null;
m_LobbyReadyCheck.EndCheckingForReady();
m_localLobby.CopyObserved(new LobbyInfo(), new Dictionary<string, LobbyUser>());
m_localLobby.CountDownTime = 0;
m_localLobby.RelayServer = null;
m_ReadyCheck.EndCheckingForReady();
}
void OnDestroy()

bool OnWantToQuit()
{
bool canQuit = string.IsNullOrEmpty(m_lobbyData?.RoomID);
bool canQuit = string.IsNullOrEmpty(m_localLobby?.LobbyID);
StartCoroutine(LeaveBeforeQuit());
return canQuit;
}

Locator.Get.Messenger.Unsubscribe(this);
if (!string.IsNullOrEmpty(m_lobbyData?.RoomID))
if (!string.IsNullOrEmpty(m_localLobby?.LobbyID))
RoomsQuery.Instance.LeaveRoomAsync(m_lobbyData?.RoomID, null);
m_lobbyData = null;
LobbyAsyncRequests.Instance.LeaveLobbyAsync(m_localLobby?.LobbyID, null);
m_localLobby = null;
/// In builds, if we are in a room and try to send a Leave request on application quit, it won't go through if we're quitting on the same frame.
/// In builds, if we are in a lobby and try to send a Leave request on application quit, it won't go through if we're quitting on the same frame.
// TEMP: Since we're temporarily (as of 6/31/21) deleting empty rooms when we leave them manually, we'll delay a bit to ensure that happens.
// TEMP: Since we're temporarily (as of 6/31/21) deleting empty lobbies when we leave them manually, we'll delay longer to ensure that happens.
//yield return null;
yield return new WaitForSeconds(0.5f);
Application.Quit();

6
Assets/Scripts/Entities/LobbyServiceData.cs


}
/// <summary>
/// Holds the latest service data, such as the list of rooms
/// Holds data related to the Lobby service itself - The latest retrieved lobby list, the state of retrieval.
/// </summary>
[System.Serializable]
public class LobbyServiceData : Observed<LobbyServiceData>

}
}
Dictionary<string, LobbyData> m_currentLobbies = new Dictionary<string, LobbyData>();
Dictionary<string, LocalLobby> m_currentLobbies = new Dictionary<string, LocalLobby>();
public Dictionary<string, LobbyData> CurrentLobbies
public Dictionary<string, LocalLobby> CurrentLobbies
{
get { return m_currentLobbies; }
set

14
Assets/Scripts/Entities/LobbyUser.cs


{
/// <summary>
/// Current state of the user in the lobby.
/// Set as a flag to allow for the unity inspector to select multiples for various UI features.
/// This is a Flags enum to allow for the Inspector to select multiples for various UI features.
Lobby = 1, // Connected to lobby, not ready yet
Ready = 4, // User clicked ready
Connecting = 8, // User sent join request through relay
Connected = 16, // User connected through relay
Menu = 32, // User is in a menu, external to the lobby
Lobby = 1, // Connected to lobby, not ready yet
Ready = 4, // User clicked ready (Note that 2 is missing; some flags have been removed over time, but we want any serialized values to be unaffected.)
Connecting = 8, // User sent join request through Relay
Connected = 16, // User connected through Relay
Menu = 32, // User is in a menu, external to the lobby
/// Lobby Room Data for a player
/// Data for a local player instance. This will update data and is observed to know when to push local player changes to the entire lobby.
/// </summary>
[Serializable]
public class LobbyUser : Observed<LobbyUser>

542
Assets/Scripts/Entities/LocalLobby.cs


using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace LobbyRelaySample
{
[Flags]
public enum LobbyState
{
Lobby = 1,
CountDown = 2,
InGame = 4
}
public struct LobbyInfo
{
public string RoomID { get; set; }
public string RoomCode { get; set; }
public string RelayCode { get; set; }
public string LobbyName { get; set; }
public bool Private { get; set; }
public int MaxPlayerCount { get; set; }
public LobbyState State { get; set; }
public long? AllPlayersReadyTime { get; set; }
public LobbyInfo(LobbyInfo existing)
{
RoomID = existing.RoomID;
RoomCode = existing.RoomCode;
RelayCode = existing.RelayCode;
LobbyName = existing.LobbyName;
Private = existing.Private;
MaxPlayerCount = existing.MaxPlayerCount;
State = existing.State;
AllPlayersReadyTime = existing.AllPlayersReadyTime;
}
public LobbyInfo(string roomCode)
{
RoomID = null;
RoomCode = roomCode;
RelayCode = null;
LobbyName = null;
Private = false;
MaxPlayerCount = -1;
State = LobbyState.Lobby;
AllPlayersReadyTime = null;
}
}
/// <summary>
/// The local lobby data that the game can observe
/// </summary>
[System.Serializable]
public class LobbyData : Observed<LobbyData>
{
Dictionary<string, LobbyUser> m_LobbyUsers = new Dictionary<string, LobbyUser>();
public Dictionary<string, LobbyUser> LobbyUsers => m_LobbyUsers;
#region LocalLobbyData
private LobbyInfo m_data;
public LobbyInfo Data
{
get { return new LobbyInfo(m_data); }
}
float m_CountDownTime;
public float CountDownTime
{
get { return m_CountDownTime; }
set
{
m_CountDownTime = value;
OnChanged(this);
}
}
DateTime m_TargetEndTime;
public DateTime TargetEndTime
{
get => m_TargetEndTime;
set
{
m_TargetEndTime = value;
OnChanged(this);
}
}
ServerAddress m_relayServer;
public ServerAddress RelayServer
{
get => m_relayServer;
set
{
m_relayServer = value;
OnChanged(this);
}
}
#endregion
public void AddPlayer(LobbyUser user)
{
if (m_LobbyUsers.ContainsKey(user.ID))
{
Debug.LogError($"Cant add player {user.DisplayName}({user.ID}) to room: {RoomID} twice");
return;
}
DoAddPlayer(user);
OnChanged(this);
}
private void DoAddPlayer(LobbyUser user)
{
m_LobbyUsers.Add(user.ID, user);
user.onChanged += OnChangedUser;
}
public void RemovePlayer(LobbyUser user)
{
DoRemoveUser(user);
OnChanged(this);
}
private void DoRemoveUser(LobbyUser user)
{
if (!m_LobbyUsers.ContainsKey(user.ID))
{
Debug.LogWarning($"Player {user.DisplayName}({user.ID}) does not exist in room: {RoomID}");
return;
}
m_LobbyUsers.Remove(user.ID);
user.onChanged -= OnChangedUser;
}
private void OnChangedUser(LobbyUser user)
{
OnChanged(this);
}
public string RoomID
{
get => m_data.RoomID;
set
{
m_data.RoomID = value;
OnChanged(this);
}
}
public string RoomCode
{
get => m_data.RoomCode;
set
{
m_data.RoomCode = value;
OnChanged(this);
}
}
public string RelayCode
{
get => m_data.RelayCode;
set
{
m_data.RelayCode = value;
OnChanged(this);
}
}
public string LobbyName
{
get => m_data.LobbyName;
set
{
m_data.LobbyName = value;
OnChanged(this);
}
}
public LobbyState State
{
get => m_data.State;
set
{
m_data.State = value;
OnChanged(this);
}
}
public bool Private
{
get => m_data.Private;
set
{
m_data.Private = value;
OnChanged(this);
}
}
public int PlayerCount => m_LobbyUsers.Count;
public int MaxPlayerCount
{
get => m_data.MaxPlayerCount;
set
{
m_data.MaxPlayerCount = value;
OnChanged(this);
}
}
public long? AllPlayersReadyTime => m_data.AllPlayersReadyTime;
/// <summary>
/// Checks if we have n players that have the Status.
/// -1 Count means you need all Lobbyusers
/// </summary>
/// <returns>True if enough players are of the input status.</returns>
public bool PlayersOfState(UserStatus status, int playersCount = -1)
{
var statePlayers = m_LobbyUsers.Values.Count(user => user.UserStatus == status);
if (playersCount < 0)
return statePlayers == m_LobbyUsers.Count;
return statePlayers == playersCount;
}
public void CopyObserved(LobbyInfo info, Dictionary<string, LobbyUser> oldUsers)
{
m_data = info;
if (oldUsers == null)
m_LobbyUsers = new Dictionary<string, LobbyUser>();
else
{
List<LobbyUser> toRemove = new List<LobbyUser>();
foreach (var user in m_LobbyUsers)
{
if (oldUsers.ContainsKey(user.Key))
user.Value.CopyObserved(oldUsers[user.Key]);
else
toRemove.Add(user.Value);
}
foreach (var remove in toRemove)
{
DoRemoveUser(remove);
}
foreach (var oldUser in oldUsers)
{
if (!m_LobbyUsers.ContainsKey(oldUser.Key))
DoAddPlayer(oldUser.Value);
}
}
OnChanged(this);
}
public override void CopyObserved(LobbyData oldObserved)
{
CopyObserved(oldObserved.Data, oldObserved.m_LobbyUsers);
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace LobbyRelaySample
{
[Flags]
public enum LobbyState
{
Lobby = 1,
CountDown = 2,
InGame = 4
}
public struct LobbyInfo
{
public string LobbyID { get; set; }
public string LobbyCode { get; set; }
public string RelayCode { get; set; }
public string LobbyName { get; set; }
public bool Private { get; set; }
public int MaxPlayerCount { get; set; }
public LobbyState State { get; set; }
public long? AllPlayersReadyTime { get; set; }
public LobbyInfo(LobbyInfo existing)
{
LobbyID = existing.LobbyID;
LobbyCode = existing.LobbyCode;
RelayCode = existing.RelayCode;
LobbyName = existing.LobbyName;
Private = existing.Private;
MaxPlayerCount = existing.MaxPlayerCount;
State = existing.State;
AllPlayersReadyTime = existing.AllPlayersReadyTime;
}
public LobbyInfo(string lobbyCode)
{
LobbyID = null;
LobbyCode = lobbyCode;
RelayCode = null;
LobbyName = null;
Private = false;
MaxPlayerCount = -1;
State = LobbyState.Lobby;
AllPlayersReadyTime = null;
}
}
/// <summary>
/// A local wrapper around a lobby's remote data, with additional functionality for providing that data to UI elements and tracking local player objects.
/// </summary>
[System.Serializable]
public class LocalLobby : Observed<LocalLobby>
{
Dictionary<string, LobbyUser> m_LobbyUsers = new Dictionary<string, LobbyUser>();
public Dictionary<string, LobbyUser> LobbyUsers => m_LobbyUsers;
#region LocalLobbyData
private LobbyInfo m_data;
public LobbyInfo Data
{
get { return new LobbyInfo(m_data); }
}
float m_CountDownTime;
public float CountDownTime
{
get { return m_CountDownTime; }
set
{
m_CountDownTime = value;
OnChanged(this);
}
}
DateTime m_TargetEndTime;
public DateTime TargetEndTime
{
get => m_TargetEndTime;
set
{
m_TargetEndTime = value;
OnChanged(this);
}
}
ServerAddress m_relayServer;
public ServerAddress RelayServer
{
get => m_relayServer;
set
{
m_relayServer = value;
OnChanged(this);
}
}
#endregion
public void AddPlayer(LobbyUser user)
{
if (m_LobbyUsers.ContainsKey(user.ID))
{
Debug.LogError($"Cant add player {user.DisplayName}({user.ID}) to lobby: {LobbyID} twice");
return;
}
DoAddPlayer(user);
OnChanged(this);
}
private void DoAddPlayer(LobbyUser user)
{
m_LobbyUsers.Add(user.ID, user);
user.onChanged += OnChangedUser;
}
public void RemovePlayer(LobbyUser user)
{
DoRemoveUser(user);
OnChanged(this);
}
private void DoRemoveUser(LobbyUser user)
{
if (!m_LobbyUsers.ContainsKey(user.ID))
{
Debug.LogWarning($"Player {user.DisplayName}({user.ID}) does not exist in lobby: {LobbyID}");
return;
}
m_LobbyUsers.Remove(user.ID);
user.onChanged -= OnChangedUser;
}
private void OnChangedUser(LobbyUser user)
{
OnChanged(this);
}
public string LobbyID
{
get => m_data.LobbyID;
set
{
m_data.LobbyID = value;
OnChanged(this);
}
}
public string LobbyCode
{
get => m_data.LobbyCode;
set
{
m_data.LobbyCode = value;
OnChanged(this);
}
}
public string RelayCode
{
get => m_data.RelayCode;
set
{
m_data.RelayCode = value;
OnChanged(this);
}
}
public string LobbyName
{
get => m_data.LobbyName;
set
{
m_data.LobbyName = value;
OnChanged(this);
}
}
public LobbyState State
{
get => m_data.State;
set
{
m_data.State = value;
OnChanged(this);
}
}
public bool Private
{
get => m_data.Private;
set
{
m_data.Private = value;
OnChanged(this);
}
}
public int PlayerCount => m_LobbyUsers.Count;
public int MaxPlayerCount
{
get => m_data.MaxPlayerCount;
set
{
m_data.MaxPlayerCount = value;
OnChanged(this);
}
}
public long? AllPlayersReadyTime => m_data.AllPlayersReadyTime;
/// <summary>
/// Checks if we have n players that have the Status.
/// -1 Count means you need all Lobbyusers
/// </summary>
/// <returns>True if enough players are of the input status.</returns>
public bool PlayersOfState(UserStatus status, int playersCount = -1)
{
var statePlayers = m_LobbyUsers.Values.Count(user => user.UserStatus == status);
if (playersCount < 0)
return statePlayers == m_LobbyUsers.Count;
return statePlayers == playersCount;
}
public void CopyObserved(LobbyInfo info, Dictionary<string, LobbyUser> oldUsers)
{
m_data = info;
if (oldUsers == null)
m_LobbyUsers = new Dictionary<string, LobbyUser>();
else
{
List<LobbyUser> toRemove = new List<LobbyUser>();
foreach (var user in m_LobbyUsers)
{
if (oldUsers.ContainsKey(user.Key))
user.Value.CopyObserved(oldUsers[user.Key]);
else
toRemove.Add(user.Value);
}
foreach (var remove in toRemove)
{
DoRemoveUser(remove);
}
foreach (var oldUser in oldUsers)
{
if (!m_LobbyUsers.ContainsKey(oldUser.Key))
DoAddPlayer(oldUser.Value);
}
}
OnChanged(this);
}
public override void CopyObserved(LocalLobby oldObserved)
{
CopyObserved(oldObserved.Data, oldObserved.m_LobbyUsers);
}
}
}

24
Assets/Scripts/Tests/Editor/LobbyTests.cs


{
public class LobbyTests
{
LobbyData m_LobbyData;
LocalLobby m_LocalLobby;
const int k_TestUserCount = 3;

m_LobbyData = new LobbyData();
m_LocalLobby = new LocalLobby();
m_LobbyData.AddPlayer(new LobbyUser
m_LocalLobby.AddPlayer(new LobbyUser
{
ID = i.ToString()
});

[Test]
public void LobbyPlayerStateTest()
{
Assert.False(m_LobbyData.PlayersOfState(UserStatus.Ready));
Assert.False(m_LocalLobby.PlayersOfState(UserStatus.Ready));
m_LobbyData.LobbyUsers["0"].UserStatus = UserStatus.Ready;
Assert.False(m_LobbyData.PlayersOfState(UserStatus.Ready));
Assert.True(m_LobbyData.PlayersOfState(UserStatus.Ready, 1));
m_LocalLobby.LobbyUsers["0"].UserStatus = UserStatus.Ready;
Assert.False(m_LocalLobby.PlayersOfState(UserStatus.Ready));
Assert.True(m_LocalLobby.PlayersOfState(UserStatus.Ready, 1));
m_LobbyData.LobbyUsers["1"].UserStatus = UserStatus.Ready;
Assert.False(m_LobbyData.PlayersOfState(UserStatus.Ready));
Assert.True(m_LobbyData.PlayersOfState(UserStatus.Ready, 2));
m_LocalLobby.LobbyUsers["1"].UserStatus = UserStatus.Ready;
Assert.False(m_LocalLobby.PlayersOfState(UserStatus.Ready));
Assert.True(m_LocalLobby.PlayersOfState(UserStatus.Ready, 2));
m_LobbyData.LobbyUsers["2"].UserStatus = UserStatus.Ready;
m_LocalLobby.LobbyUsers["2"].UserStatus = UserStatus.Ready;
Assert.True(m_LobbyData.PlayersOfState(UserStatus.Ready));
Assert.True(m_LocalLobby.PlayersOfState(UserStatus.Ready));
}
}
}

6
Assets/Scripts/Tests/Editor/ObserverTests.cs


public IEnumerator ObserverChangeWhenObservedChanged() // Test if Observer changes when StringField gets set
{
var observed = new TestObserved();
var observer = new GameObject("PlayerObserver").AddComponent<TestObservereBehaviour>();
var observer = new GameObject("PlayerObserver").AddComponent<TestObserverBehaviour>();
observer.BeginObserving(observed);
Assert.AreNotEqual("NewName", observed.StringField);

var observed = new TestObserved();
observed.StringField = "NewName"; // Set the field before we begin observing
var observer = new GameObject("PlayerObserver").AddComponent<TestObservereBehaviour>();
var observer = new GameObject("PlayerObserver").AddComponent<TestObserverBehaviour>();
Assert.AreNotEqual(observed.StringField, observer.displayStringField);
observer.BeginObserving(observed);

}
//Mock UI Observer
class TestObservereBehaviour : ObserverBehaviour<TestObserved>
class TestObserverBehaviour : ObserverBehaviour<TestObserved>
{
public string displayStringField;

2
Assets/Scripts/Tests/Editor/Tests.Editor.asmdef


"references": [
"UnityEngine.TestRunner",
"UnityEditor.TestRunner",
"LobbyRooms"
"LobbyRelaySample"
],
"includePlatforms": [
"Editor"

2
Assets/Scripts/Tests/PlayMode/Tests.Play.asmdef


"references": [
"UnityEngine.TestRunner",
"UnityEditor.TestRunner",
"LobbyRooms",
"LobbyRelaySample",
"Unity.Services.Rooms",
"Unity.Services.Relay"
],

3
Assets/Scripts/UI/BackButtonUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// For navigating the main menu.
/// </summary>
public class BackButtonUI : MonoBehaviour
{
public void ToJoinMenu()

7
Assets/Scripts/UI/CountdownUI.cs


namespace LobbyRelaySample.UI
{
public class CountdownUI : ObserverPanel<LobbyData>
/// <summary>
/// After all players ready up for the game, this will show the countdown that occurs.
/// </summary>
public class CountdownUI : ObserverPanel<LocalLobby>
public override void ObservedUpdated(LobbyData observed)
public override void ObservedUpdated(LocalLobby observed)
{
if (observed.CountDownTime <= 0)
return;

7
Assets/Scripts/UI/CreateMenuUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Handles the menu for a player creating a new lobby.
/// </summary>
LobbyData m_ServerRequestData = new LobbyData { LobbyName = "New Lobby", MaxPlayerCount = 4 };
LocalLobby m_ServerRequestData = new LocalLobby { LobbyName = "New Lobby", MaxPlayerCount = 4 };
public void SetServerName(string serverName)
{

public void OnCreatePressed()
{
Locator.Get.Messenger.OnReceiveMessage(MessageType.CreateRoomRequest, m_ServerRequestData);
Locator.Get.Messenger.OnReceiveMessage(MessageType.CreateLobbyRequest, m_ServerRequestData);
}
}
}

3
Assets/Scripts/UI/EmoteButtonUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Controls a button which will set the local player's emote state when pressed. This demonstrates a player updating their data within the room.
/// </summary>
public class EmoteButtonUI : MonoBehaviour
{
[SerializeField]

5
Assets/Scripts/UI/EndGameButtonUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// After connecting to Relay, the host can use this to end the game, returning to the regular lobby state.
/// </summary>
public void EndServer()
public void EndGame()
{
Locator.Get.Messenger.OnReceiveMessage(MessageType.ToLobby, null);
}

3
Assets/Scripts/UI/ExitButtonUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// When the main menu's Exit button is selected, send a quit signal.
/// </summary>
public class ExitButtonUI : MonoBehaviour
{
public void OnExitButton()

3
Assets/Scripts/UI/GameStateVisibilityUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Show or hide a UI element based on the current GameState (e.g. in a lobby).
/// </summary>
[RequireComponent(typeof(LocalGameStateObserver))]
public class GameStateVisibilityUI : ObserverPanel<LocalGameState>
{

61
Assets/Scripts/UI/JoinMenuUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Handles the list of LobbyButtons and ensures it stays synchronous with the list from the service.
/// Handles the list of LobbyButtons and ensures it stays synchronized with the lobby list from the service.
/// </summary>
public class JoinMenuUI : ObserverPanel<LobbyServiceData>
{

[SerializeField]
TMP_InputField m_RoomCodeField;
TMP_InputField m_LobbyCodeField;
RectTransform m_RoomButtonParent;
RectTransform m_LobbyButtonParent;
Dictionary<string, LobbyData> m_LobbyData = new Dictionary<string, LobbyData>();
string m_targetRoomID;
string m_targetRoomJoinCode;
Dictionary<string, LocalLobby> m_LocalLobby = new Dictionary<string, LocalLobby>();
/// <summary>Contains some amount of information used to join an existing room.</summary>
LobbyInfo m_lobbyDataSelected;
/// <summary>Contains some amount of information used to join an existing lobby.</summary>
LobbyInfo m_LocalLobbySelected;
public void LobbyButtonSelected(LobbyData lobby)
public void LobbyButtonSelected(LocalLobby lobby)
m_lobbyDataSelected = lobby.Data;
m_LocalLobbySelected = lobby.Data;
m_lobbyDataSelected = new LobbyInfo(newCode.ToUpper());
m_LocalLobbySelected = new LobbyInfo(newCode.ToUpper());
Locator.Get.Messenger.OnReceiveMessage(MessageType.JoinRoomRequest, m_lobbyDataSelected);
Locator.Get.Messenger.OnReceiveMessage(MessageType.JoinLobbyRequest, m_LocalLobbySelected);
Locator.Get.Messenger.OnReceiveMessage(MessageType.QueryRooms, null);
Locator.Get.Messenger.OnReceiveMessage(MessageType.QueryLobbies, null);
}
public override void ObservedUpdated(LobbyServiceData observed)

foreach (var codeLobby in observed.CurrentLobbies)
{
var roomCodeKey = codeLobby.Key;
var lobbyCodeKey = codeLobby.Key;
if (!m_LobbyButtons.ContainsKey(roomCodeKey))
if (!m_LobbyButtons.ContainsKey(lobbyCodeKey))
AddNewLobbyButton(roomCodeKey, lobbyData);
AddNewLobbyButton(lobbyCodeKey, lobbyData);
UpdateLobbyButton(roomCodeKey, lobbyData);
UpdateLobbyButton(lobbyCodeKey, lobbyData);
previousKeys.Remove(roomCodeKey);
previousKeys.Remove(lobbyCodeKey);
RemoveLobbyButton(m_LobbyData[key]);
RemoveLobbyButton(m_LocalLobby[key]);
bool CanDisplay(LobbyData lobby)
bool CanDisplay(LocalLobby lobby)
{
return lobby.Data.State == LobbyState.Lobby && !lobby.Private;
}

/// </summary>
void AddNewLobbyButton(string roomCode, LobbyData lobby)
void AddNewLobbyButton(string lobbyCode, LocalLobby lobby)
var lobbyButtonInstance = Instantiate(m_LobbyButtonPrefab, m_RoomButtonParent);
lobbyButtonInstance.GetComponent<LobbyDataObserver>().BeginObserving(lobby);
var lobbyButtonInstance = Instantiate(m_LobbyButtonPrefab, m_LobbyButtonParent);
lobbyButtonInstance.GetComponent<LocalLobbyObserver>().BeginObserving(lobby);
m_LobbyButtons.Add(roomCode, lobbyButtonInstance);
m_LobbyData.Add(roomCode, lobby);
m_LobbyButtons.Add(lobbyCode, lobbyButtonInstance);
m_LocalLobby.Add(lobbyCode, lobby);
void UpdateLobbyButton(string roomCode, LobbyData lobby)
void UpdateLobbyButton(string lobbyCode, LocalLobby lobby)
m_LobbyButtons[roomCode].UpdateLobby(lobby);
m_LobbyButtons[lobbyCode].UpdateLobby(lobby);
void RemoveLobbyButton(LobbyData lobby)
void RemoveLobbyButton(LocalLobby lobby)
var lobbyID = lobby.RoomID;
var lobbyID = lobby.LobbyID;
lobbyButton.GetComponent<LobbyDataObserver>().EndObserving();
lobbyButton.GetComponent<LocalLobbyObserver>().EndObserving();
m_LobbyData.Remove(lobbyID);
m_LocalLobby.Remove(lobbyID);
Destroy(lobbyButton.gameObject);
}
}

15
Assets/Scripts/UI/LobbyButtonUI.cs


namespace LobbyRelaySample.UI
{
[RequireComponent(typeof(LobbyDataObserver))]
/// <summary>
/// Controls an entry in the join menu's list of lobbies, acting as a clickable button as well as displaying info about the lobby.
/// </summary>
[RequireComponent(typeof(LocalLobbyObserver))]
public class LobbyButtonUI : MonoBehaviour
{
[SerializeField]

/// <summary>
/// Subscribed to on instantiation to pass our lobby data back
/// </summary>
public UnityEvent<LobbyData> onLobbyPressed;
LobbyDataObserver m_DataObserver;
public UnityEvent<LocalLobby> onLobbyPressed;
LocalLobbyObserver m_DataObserver;
m_DataObserver = GetComponent<LobbyDataObserver>();
m_DataObserver = GetComponent<LocalLobbyObserver>();
}
/// <summary>

onLobbyPressed?.Invoke(m_DataObserver.observed);
}
public void UpdateLobby(LobbyData lobby)
public void UpdateLobby(LocalLobby lobby)
public void OnRoomUpdated(LobbyData data)
public void OnLobbyUpdated(LocalLobby data)
{
lobbyNameText.SetText(data.LobbyName);
lobbyCountText.SetText($"{data.PlayerCount}/{data.MaxPlayerCount}");

2
Assets/Scripts/UI/MainMenuUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Watches for Changes in the Game State to/from Menu
/// Watches for changes in the game state to/from the main menu.
/// </summary>
[RequireComponent(typeof(LocalGameStateObserver))]
public class MainMenuUI : ObserverPanel<LocalGameState>

2
Assets/Scripts/UI/NameChangeUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Sends a message that should change the displayName Data only.
/// When the player changes their name with the UI, this triggers the actual rename.
/// </summary>
public class NameChangeUI : UIPanelBase
{

5
Assets/Scripts/UI/ObserverPanel.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Observer UI panel base class, for UI panels that need hiding, and hookup to observerBehaviours
/// Observer UI panel base class. This allows UI elements to be shown or hidden based on an Observed element.
/// <typeparam name="T"></typeparam>
}

3
Assets/Scripts/UI/PlayerNameUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Displays the player's name.
/// </summary>
public class PlayerNameUI : ObserverPanel<LobbyUser>
{
[SerializeField]

3
Assets/Scripts/UI/ReadyCheckUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Button callbacks for the "Ready"/"Not Ready" buttons used to indicate the local player is ready/not ready.
/// </summary>
public class ReadyCheckUI : MonoBehaviour
{
public void OnReadyButton()

3
Assets/Scripts/UI/SpinnerUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Controls a simple throbber that is displayed when the lobby list is being refreshed.
/// </summary>
public class SpinnerUI : ObserverPanel<LobbyServiceData>
{
public TMP_Text errorText;

3
Assets/Scripts/UI/UIPanelBase.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Basic UI element that can be shown or hidden.
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public class UIPanelBase : MonoBehaviour
{

5
Assets/Scripts/UI/UserStateVisibilityUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Current user statea
/// Set as a flag to allow for the unity inspector to select multiples for various UI features.
/// User permission type. It's a flag enum to allow for the Inspector to select multiples for various UI features.
/// </summary>
[Flags]
public enum UserPermission

}
/// <summary>
/// Shows the UI when the lobbyuser is set to the matching conditions.
/// Shows the UI when the LobbyUser matches some conditions, including having the target permissions.
/// </summary>
[RequireComponent(typeof(LobbyUserObserver))]
public class UserStateVisibilityUI : ObserverPanel<LobbyUser>

42
Assets/Scripts/UI/JoinCreateLobbyUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// The panel that holds the room joining and creation panels.
/// </summary>
public class JoinCreateRoomUI : ObserverPanel<LocalGameState>
{
public override void ObservedUpdated(LocalGameState observed)
{
if (observed.State == GameState.JoinMenu)
{
Show();
Locator.Get.Messenger.OnReceiveMessage(MessageType.QueryRooms, null);
}
else
{
Hide();
}
}
}
}
namespace LobbyRelaySample.UI
{
/// <summary>
/// The panel that holds the lobby joining and creation panels.
/// </summary>
public class JoinCreateLobbyUI : ObserverPanel<LocalGameState>
{
public override void ObservedUpdated(LocalGameState observed)
{
if (observed.State == GameState.JoinMenu)
{
Show();
Locator.Get.Messenger.OnReceiveMessage(MessageType.QueryLobbies, null);
}
else
{
Hide();
}
}
}
}

4
Assets/Scripts/UI/InLobbyUserUI.cs


namespace LobbyRelaySample.UI
{
/// <summary>
/// Shows the player info in-lobby and game
/// When inside a lobby, this will show information about a player, whether local or remote.
public class LobbyUserCardUI : ObserverPanel<LobbyUser>
public class InLobbyUserUI : ObserverPanel<LobbyUser>
{
[SerializeField]
TMP_Text m_DisplayNameText;

2
Assets/Scripts/LobbyRelaySample.asmdef


{
"name": "LobbyRooms",
"name": "LobbyRelaySample",
"rootNamespace": "",
"references": [
"GUID:6055be8ebefd69e48b49212b09b47b2f",

61
README.md


# Lobby Rooms
**com.unity.services.samples.lobby-rooms**
A Unity Project Sample showing how to integrate Rooms and Relay into a typical Lobby experience use case.
### Closed Beta - 7/14/21
Lobby and Relay are **only** available in closed beta at the moment, to use these services you will need to have signed up here for the services to show in your Organization: https://create.unity3d.com/relay-lobby-beta-signup
# Game Lobby Sample
## *Unity 2021.0b1*
This is a Unity Project Sample showing how to integrate Lobby and Relay into a typical Game Lobby experience.
Features Covered:
- Lobby Creation

- Relay Code Generation
- Relay Server Join
# Service Setup
** Create an organization.
## Service Organization Setup
** Create an organization.**
In the project, navigate to **Edit => Project Settings => Services**
![Services Editor](~Documentation/Images/services1.PNG?raw=true)
Select your organization from the drop-down, and push **Create Project ID**.
In the end your Service window should look like this!
![Services Editor Complete](~Documentation/Images/services2.PNG?raw=true)
## Lobby
# Lobby:
Navigate to https://dashboard.unity3d.com/lobby
# Relay:
We use the Relay service to obfuscate the Hosts' IP, while still allowing them to locally host strangers.
*(This will only be visibile if you are in the closed beta)*
In the bottom left, select "Get Started"
[Relay Overview](http://documentation.cloud.unity3d.com/en/articles/5371723-relay-overview)
Follow the steps until you hit "Lobby On"
Navigate to https://dashboard.unity3d.com/landing
Select Relay from the drop-down list
## Relay
We use the Relay service to obfuscate the Hosts' IP, while still allowing them to locally host strangers.
[Relay Overview](http://documentation.cloud.unity3d.com/en/articles/5371723-relay-overview)
![Relay](~Documentation/Images/dashboard1_beta.png?raw=true "Relay location.")
Navigate to https://dashboard.unity3d.com/relay
Select your project
*(This will only be visibile if you are in the closed beta)*
(For this project, you can skip downloading the Transport)
*(Current version of the sample does not use transport, so you may skip it.)*
# Solo Testing
## Unity Editor Setup
In the project, navigate to **Edit => Project Settings => Services**
![Services Editor](~Documentation/Images/services1.PNG?raw=true)
Select your organization from the drop-down, and push **Create Project ID**.
In the end your Service window should look like this!
![Services Editor Complete](~Documentation/Images/services2.PNG?raw=true)
## Solo Testing
Create a new Unity Build of the project in the OS of your choice.
Because the Authentication service creates a unique ID for builds, you will need to host a lobby in Build and join in Editor or vice versa.

999
~Documentation/Images/dashboard1_beta.png
文件差异内容过多而无法显示
查看文件

999
~Documentation/Images/services1.PNG
文件差异内容过多而无法显示
查看文件

999
~Documentation/Images/services2.PNG
文件差异内容过多而无法显示
查看文件

251
~Documentation/Images/tutorial_1_lobbyList.png
文件差异内容过多而无法显示
查看文件

353
~Documentation/Images/tutorial_2_createMenu.png
文件差异内容过多而无法显示
查看文件

565
~Documentation/Images/tutorial_3_HostGame.png
文件差异内容过多而无法显示
查看文件

352
~Documentation/Images/tutorial_4_newLobby.png
文件差异内容过多而无法显示
查看文件

947
~Documentation/Images/tutorial_5_editorCow.png
文件差异内容过多而无法显示
查看文件

803
~Documentation/Images/tutorial_6_countDown.png

之前 之后
宽度: 1920  |  高度: 1080  |  大小: 157 KiB

971
~Documentation/Images/tutorial_7_ingame.png
文件差异内容过多而无法显示
查看文件

200
Assets/Scripts/Infrastructure/Locator.cs


using LobbyRelaySample.Auth;
using System;
using System.Collections.Generic;
namespace LobbyRelaySample
{
/// <summary>
/// Allows Located services to transfer data to their replacements if needed.
/// </summary>
/// <typeparam name="T">The base interface type you want to Provide.</typeparam>
public interface IProvidable<T>
{
void OnReProvided(T previousProvider);
}
/// <summary>
/// Base Locator behavior, without static access.
/// </summary>
public class LocatorBase
{
private Dictionary<Type, object> m_provided = new Dictionary<Type, object>();
/// <summary>
/// On construction, we can prepare default implementations of any services we expect to be required. This way, if for some reason the actual implementations
/// are never Provided (e.g. for tests), nothing will break.
/// </summary>
public LocatorBase()
{
Provide(new Messenger());
Provide(new UpdateSlowNoop());
Provide(new IdentityNoop());
FinishConstruction();
}
protected virtual void FinishConstruction() { }
/// <summary>
/// Call this to indicate that something is available for global access.
/// </summary>
private void ProvideAny<T>(T instance) where T : IProvidable<T>
{
Type type = typeof(T);
if (m_provided.ContainsKey(type))
{
var previousProvision = (T)m_provided[type];
instance.OnReProvided(previousProvision);
m_provided.Remove(type);
}
m_provided.Add(type, instance);
}
/// <summary>
/// If a T has previously been Provided, this will retrieve it. Else, null is returned.
/// </summary>
private T Locate<T>() where T : class
{
Type type = typeof(T);
if (!m_provided.ContainsKey(type))
return null;
return m_provided[type] as T;
}
// To limit global access to only components that should have it, and to reduce programmer error, we'll declare explicit flavors of Provide and getters for them.
public IMessenger Messenger => Locate<IMessenger>();
public void Provide(IMessenger messenger) { ProvideAny(messenger); }
public IUpdateSlow UpdateSlow => Locate<IUpdateSlow>();
public void Provide(IUpdateSlow updateSlow) { ProvideAny(updateSlow); }
public IIdentity Identity => Locate<IIdentity>();
public void Provide(IIdentity identity) { ProvideAny(identity); }
// As you add more Provided types, be sure their default implementations are included in the constructor.
}
/// <summary>
/// Anything which provides itself to a Locator can then be globally accessed. This should be a single access point for things that *want* to be singleton (that is,
/// when they want to be available for use by arbitrary, unknown clients) but might not always be available or might need alternate flavors for tests, logging, etc.
/// </summary>
public class Locator : LocatorBase
{
private static Locator s_instance;
public static Locator Get
{
get
{
if (s_instance == null)
s_instance = new Locator();
return s_instance;
}
}
protected override void FinishConstruction()
{
s_instance = this;
}
}
using LobbyRelaySample.Auth;
using System;
using System.Collections.Generic;
namespace LobbyRelaySample
{
/// <summary>
/// Allows Located services to transfer data to their replacements if needed.
/// </summary>
/// <typeparam name="T">The base interface type you want to Provide.</typeparam>
public interface IProvidable<T>
{
void OnReProvided(T previousProvider);
}
/// <summary>
/// Base Locator behavior, without static access.
/// </summary>
public class LocatorBase
{
private Dictionary<Type, object> m_provided = new Dictionary<Type, object>();
/// <summary>
/// On construction, we can prepare default implementations of any services we expect to be required. This way, if for some reason the actual implementations
/// are never Provided (e.g. for tests), nothing will break.
/// </summary>
public LocatorBase()
{
Provide(new Messenger());
Provide(new UpdateSlowNoop());
Provide(new IdentityNoop());
FinishConstruction();
}
protected virtual void FinishConstruction() { }
/// <summary>
/// Call this to indicate that something is available for global access.
/// </summary>
private void ProvideAny<T>(T instance) where T : IProvidable<T>
{
Type type = typeof(T);
if (m_provided.ContainsKey(type))
{
var previousProvision = (T)m_provided[type];
instance.OnReProvided(previousProvision);
m_provided.Remove(type);
}
m_provided.Add(type, instance);
}
/// <summary>
/// If a T has previously been Provided, this will retrieve it. Else, null is returned.
/// </summary>
private T Locate<T>() where T : class
{
Type type = typeof(T);
if (!m_provided.ContainsKey(type))
return null;
return m_provided[type] as T;
}
// To limit global access to only components that should have it, and to reduce programmer error, we'll declare explicit flavors of Provide and getters for them.
public IMessenger Messenger => Locate<IMessenger>();
public void Provide(IMessenger messenger) { ProvideAny(messenger); }
public IUpdateSlow UpdateSlow => Locate<IUpdateSlow>();
public void Provide(IUpdateSlow updateSlow) { ProvideAny(updateSlow); }
public IIdentity Identity => Locate<IIdentity>();
public void Provide(IIdentity identity) { ProvideAny(identity); }
// As you add more Provided types, be sure their default implementations are included in the constructor.
}
/// <summary>
/// Anything which provides itself to a Locator can then be globally accessed. This should be a single access point for things that *want* to be singleton (that is,
/// when they want to be available for use by arbitrary, unknown clients) but might not always be available or might need alternate flavors for tests, logging, etc.
/// </summary>
public class Locator : LocatorBase
{
private static Locator s_instance;
public static Locator Get
{
get
{
if (s_instance == null)
s_instance = new Locator();
return s_instance;
}
}
protected override void FinishConstruction()
{
s_instance = this;
}
}
}

126
Assets/Scripts/Infrastructure/LogHandler.cs


using System;
using UnityEngine;
using Object = UnityEngine.Object;
namespace LobbyRelaySample
{
public enum LogMode
{
Critical, // Errors only.
Warnings, // Errors and Warnings
Verbose // Everything
}
/// <summary>
/// Overrides the Default Unity Logging with our own
/// </summary>
public class LogHandler : ILogHandler
{
public LogMode mode = LogMode.Critical;
static LogHandler s_instance;
ILogHandler m_DefaultLogHandler = Debug.unityLogger.logHandler; //Store the unity default logger to print to console.
public static LogHandler Get()
{
if (s_instance != null) return s_instance;
s_instance = new LogHandler();
Debug.unityLogger.logHandler = s_instance;
return s_instance;
}
public void LogFormat(LogType logType, Object context, string format, params object[] args)
{
if (logType == LogType.Exception) // Exceptions are captured by LogException?
return;
if (logType == LogType.Error || logType == LogType.Assert)
{
m_DefaultLogHandler.LogFormat(logType, context, format, args);
return;
}
if (mode == LogMode.Critical)
return;
if (logType == LogType.Warning)
{
m_DefaultLogHandler.LogFormat(logType, context, format, args);
return;
}
if (mode != LogMode.Verbose)
return;
m_DefaultLogHandler.LogFormat(logType, context, format, args);
}
public void LogException(Exception exception, Object context)
{
m_DefaultLogHandler.LogException(exception, context);
}
}
}
using System;
using UnityEngine;
using Object = UnityEngine.Object;
namespace LobbyRelaySample
{
public enum LogMode
{
Critical, // Errors only.
Warnings, // Errors and Warnings
Verbose // Everything
}
/// <summary>
/// Overrides the Default Unity Logging with our own
/// </summary>
public class LogHandler : ILogHandler
{
public LogMode mode = LogMode.Critical;
static LogHandler s_instance;
ILogHandler m_DefaultLogHandler = Debug.unityLogger.logHandler; //Store the unity default logger to print to console.
public static LogHandler Get()
{
if (s_instance != null) return s_instance;
s_instance = new LogHandler();
Debug.unityLogger.logHandler = s_instance;
return s_instance;
}
public void LogFormat(LogType logType, Object context, string format, params object[] args)
{
if (logType == LogType.Exception) // Exceptions are captured by LogException?
return;
if (logType == LogType.Error || logType == LogType.Assert)
{
m_DefaultLogHandler.LogFormat(logType, context, format, args);
return;
}
if (mode == LogMode.Critical)
return;
if (logType == LogType.Warning)
{
m_DefaultLogHandler.LogFormat(logType, context, format, args);
return;
}
if (mode != LogMode.Verbose)
return;
m_DefaultLogHandler.LogFormat(logType, context, format, args);
}
public void LogException(Exception exception, Object context)
{
m_DefaultLogHandler.LogException(exception, context);
}
}
}

176
Assets/Scripts/Infrastructure/Messenger.cs


using System.Collections.Generic;
using UnityEngine;
using Stopwatch = System.Diagnostics.Stopwatch;
namespace LobbyRelaySample
{
/// <summary>
/// Ensure that message contents are obvious but not dependent on spelling strings correctly.
/// </summary>
public enum MessageType
{
// These are assigned arbitrary explicit values so that if a MessageType is serialized and more enum values are later inserted/removed, the serialized values need not be reassigned.
// (If you want to remove a message, make sure it isn't serialized somewhere first.)
None = 0,
RenameRequest = 1,
JoinRoomRequest = 2,
CreateRoomRequest = 3,
QueryRooms = 4,
PlayerJoinedRoom = 5,
PlayerLeftRoom = 6,
ChangeGameState = 7,
ChangeLobbyUserState = 8,
HostInitReadyCheck = 9,
LocalUserReadyCheckResponse = 10,
UserSetEmote = 11,
ToLobby = 12,
Client_EndReadyCountdownAt = 13,
}
/// <summary>
/// Something that wants to subscribe to messages from arbitrary, unknown senders.
/// </summary>
public interface IReceiveMessages
{
void OnReceiveMessage(MessageType type, object msg);
}
/// <summary>
/// Something to which IReceiveMessages can send/subscribe for arbitrary messages.
/// </summary>
public interface IMessenger : IReceiveMessages, IProvidable<IMessenger>
{
void Subscribe(IReceiveMessages receiver);
void Unsubscribe(IReceiveMessages receiver);
}
/// <summary>
/// Core mechanism for routing messages to arbitrary listeners.
/// </summary>
public class Messenger : IMessenger
{
private List<IReceiveMessages> m_receivers = new List<IReceiveMessages>();
private const float k_durationToleranceMs = 10;
/// <summary>
/// Assume that you won't receive messages in a specific order.
/// </summary>
public virtual void Subscribe(IReceiveMessages receiver)
{
if (!m_receivers.Contains(receiver))
m_receivers.Add(receiver);
}
public virtual void Unsubscribe(IReceiveMessages receiver)
{
m_receivers.Remove(receiver);
}
public virtual void OnReceiveMessage(MessageType type, object msg)
{
Stopwatch stopwatch = new Stopwatch();
for (int r = 0; r < m_receivers.Count; r++)
{
stopwatch.Restart();
m_receivers[r].OnReceiveMessage(type, msg);
stopwatch.Stop();
if (stopwatch.ElapsedMilliseconds > k_durationToleranceMs)
Debug.LogWarning($"Message recipient \"{m_receivers[r]}\" took too long to process message \"{msg}\" of type {type}");
}
}
public void OnReProvided(IMessenger previousProvider)
{
if (previousProvider is Messenger)
m_receivers.AddRange((previousProvider as Messenger).m_receivers);
}
}
}
using System.Collections.Generic;
using UnityEngine;
using Stopwatch = System.Diagnostics.Stopwatch;
namespace LobbyRelaySample
{
/// <summary>
/// Ensure that message contents are obvious but not dependent on spelling strings correctly.
/// </summary>
public enum MessageType
{
// These are assigned arbitrary explicit values so that if a MessageType is serialized and more enum values are later inserted/removed, the serialized values need not be reassigned.
// (If you want to remove a message, make sure it isn't serialized somewhere first.)
None = 0,
RenameRequest = 1,
JoinLobbyRequest = 2,
CreateLobbyRequest = 3,
QueryLobbies = 4,
PlayerJoinedLobby = 5,
PlayerLeftLobby = 6,
ChangeGameState = 7,
ChangeLobbyUserState = 8,
HostInitReadyCheck = 9,
LocalUserReadyCheckResponse = 10,
UserSetEmote = 11,
ToLobby = 12,
Client_EndReadyCountdownAt = 13,
}
/// <summary>
/// Something that wants to subscribe to messages from arbitrary, unknown senders.
/// </summary>
public interface IReceiveMessages
{
void OnReceiveMessage(MessageType type, object msg);
}
/// <summary>
/// Something to which IReceiveMessages can send/subscribe for arbitrary messages.
/// </summary>
public interface IMessenger : IReceiveMessages, IProvidable<IMessenger>
{
void Subscribe(IReceiveMessages receiver);
void Unsubscribe(IReceiveMessages receiver);
}
/// <summary>
/// Core mechanism for routing messages to arbitrary listeners.
/// </summary>
public class Messenger : IMessenger
{
private List<IReceiveMessages> m_receivers = new List<IReceiveMessages>();
private const float k_durationToleranceMs = 10;
/// <summary>
/// Assume that you won't receive messages in a specific order.
/// </summary>
public virtual void Subscribe(IReceiveMessages receiver)
{
if (!m_receivers.Contains(receiver))
m_receivers.Add(receiver);
}
public virtual void Unsubscribe(IReceiveMessages receiver)
{
m_receivers.Remove(receiver);
}
public virtual void OnReceiveMessage(MessageType type, object msg)
{
Stopwatch stopwatch = new Stopwatch();
for (int r = 0; r < m_receivers.Count; r++)
{
stopwatch.Restart();
m_receivers[r].OnReceiveMessage(type, msg);
stopwatch.Stop();
if (stopwatch.ElapsedMilliseconds > k_durationToleranceMs)
Debug.LogWarning($"Message recipient \"{m_receivers[r]}\" took too long to process message \"{msg}\" of type {type}");
}
}
public void OnReProvided(IMessenger previousProvider)
{
if (previousProvider is Messenger)
m_receivers.AddRange((previousProvider as Messenger).m_receivers);
}
}
}

5
Assets/Scripts/Auth/NameGenerator.cs


using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
/// Just for fun, give a cute default name if no name is provided.
/// Just for fun, give a cute default player name if no name is provided.
/// </summary>
public static class NameGenerator
{

276
Assets/Scripts/Infrastructure/UpdateSlow.cs


using System.Collections.Generic;
using UnityEngine;
using Stopwatch = System.Diagnostics.Stopwatch;
namespace LobbyRelaySample
{
public delegate void UpdateMethod(float dt);
public interface IUpdateSlow : IProvidable<IUpdateSlow>
{
void OnUpdate(float dt);
void Subscribe(UpdateMethod onUpdate);
void Unsubscribe(UpdateMethod onUpdate);
}
/// <summary>
/// A default implementation.
/// </summary>
public class UpdateSlowNoop : IUpdateSlow
{
public void OnUpdate(float dt) { }
public void Subscribe(UpdateMethod onUpdate) { }
public void Unsubscribe(UpdateMethod onUpdate) { }
public void OnReProvided(IUpdateSlow prev) { }
}
/// <summary>
/// Some objects might need to be on a slower update loop than the usual MonoBehaviour Update, e.g. to refresh data from services.
/// Some might also not want to be coupled to a Unity object at all but still need an update loop.
/// </summary>
public class UpdateSlow : MonoBehaviour, IUpdateSlow
{
[SerializeField]
[Tooltip("Update interval. Note that room Get requests must occur at least 1 second apart, so this period should likely be greater than that.")]
private float m_updatePeriod = 1.5f;
[SerializeField]
[Tooltip("If a subscriber to slow update takes longer than this to execute, it can be automatically unsubscribed.")]
private float m_durationToleranceMs = 10;
[SerializeField]
[Tooltip("We ordinarily automatically remove a subscriber that takes too long. Otherwise, we'll simply log.")]
private bool m_doNotRemoveIfTooLong = false;
private List<UpdateMethod> m_subscribers = new List<UpdateMethod>();
private float m_updateTimer = 0;
private int m_nextActiveSubIndex = 0; // For staggering subscribers, to prevent spikes of lots of things triggering at once.
public void Awake()
{
Locator.Get.Provide(this);
}
public void OnDestroy()
{
// We should clean up references in case they would prevent garbage collection.
m_subscribers.Clear();
}
/// <summary>Don't assume that onUpdate will be called in any particular order compared to other subscribers.</summary>
public void Subscribe(UpdateMethod onUpdate)
{
if (!m_subscribers.Contains(onUpdate))
m_subscribers.Add(onUpdate);
}
/// <summary>Safe to call even if onUpdate was not previously Subscribed.</summary>
public void Unsubscribe(UpdateMethod onUpdate)
{
int index = m_subscribers.IndexOf(onUpdate);
if (index >= 0)
{
m_subscribers.Remove(onUpdate);
if (index < m_nextActiveSubIndex)
m_nextActiveSubIndex--;
}
}
private void Update()
{
if (m_subscribers.Count == 0)
return;
m_updateTimer += Time.deltaTime;
float effectivePeriod = m_updatePeriod / m_subscribers.Count;
while (m_updateTimer > effectivePeriod)
{
m_updateTimer -= effectivePeriod;
OnUpdate(effectivePeriod);
}
}
public void OnUpdate(float dt)
{
Stopwatch stopwatch = new Stopwatch();
m_nextActiveSubIndex = System.Math.Max(0, System.Math.Min(m_subscribers.Count - 1, m_nextActiveSubIndex)); // Just a backup.
UpdateMethod onUpdate = m_subscribers[m_nextActiveSubIndex];
if (onUpdate == null || onUpdate.Target == null) // In case something forgets to Unsubscribe when it dies.
{ Remove(m_nextActiveSubIndex, $"Did not Unsubscribe from UpdateSlow: {onUpdate.Target} : {onUpdate.Method}");
return;
}
if (onUpdate.Method.ToString().Contains("<")) // Detect an anonymous or lambda or local method that cannot be Unsubscribed, by checking for a character that can't exist in a declared method name.
{ Remove(m_nextActiveSubIndex, $"Removed anonymous from UpdateSlow: {onUpdate.Target} : {onUpdate.Method}");
return;
}
stopwatch.Restart();
onUpdate?.Invoke(dt);
stopwatch.Stop();
if (stopwatch.ElapsedMilliseconds > m_durationToleranceMs)
{
if (!m_doNotRemoveIfTooLong)
Remove(m_nextActiveSubIndex, $"UpdateSlow subscriber took too long, removing: {onUpdate.Target} : {onUpdate.Method}");
else
{
Debug.LogWarning($"UpdateSlow subscriber took too long: {onUpdate.Target} : {onUpdate.Method}");
Increment();
}
}
else
Increment();
void Remove(int index, string msg)
{
m_subscribers.RemoveAt(index);
m_nextActiveSubIndex--;
Debug.LogError(msg);
Increment();
}
void Increment()
{
m_nextActiveSubIndex++;
if (m_nextActiveSubIndex >= m_subscribers.Count)
m_nextActiveSubIndex = 0;
}
}
public void OnReProvided(IUpdateSlow prevUpdateSlow)
{
if (prevUpdateSlow is UpdateSlow)
m_subscribers.AddRange((prevUpdateSlow as UpdateSlow).m_subscribers);
}
}
}
using System.Collections.Generic;
using UnityEngine;
using Stopwatch = System.Diagnostics.Stopwatch;
namespace LobbyRelaySample
{
public delegate void UpdateMethod(float dt);
public interface IUpdateSlow : IProvidable<IUpdateSlow>
{
void OnUpdate(float dt);
void Subscribe(UpdateMethod onUpdate);
void Unsubscribe(UpdateMethod onUpdate);
}
/// <summary>
/// A default implementation.
/// </summary>
public class UpdateSlowNoop : IUpdateSlow
{
public void OnUpdate(float dt) { }
public void Subscribe(UpdateMethod onUpdate) { }
public void Unsubscribe(UpdateMethod onUpdate) { }
public void OnReProvided(IUpdateSlow prev) { }
}
/// <summary>
/// Some objects might need to be on a slower update loop than the usual MonoBehaviour Update, e.g. to refresh data from services.
/// Some might also not want to be coupled to a Unity object at all but still need an update loop.
/// </summary>
public class UpdateSlow : MonoBehaviour, IUpdateSlow
{
[SerializeField]
[Tooltip("Update interval. Note that lobby Get requests must occur at least 1 second apart, so this period should likely be greater than that.")]
private float m_updatePeriod = 1.5f;
[SerializeField]
[Tooltip("If a subscriber to slow update takes longer than this to execute, it can be automatically unsubscribed.")]
private float m_durationToleranceMs = 10;
[SerializeField]
[Tooltip("We ordinarily automatically remove a subscriber that takes too long. Otherwise, we'll simply log.")]
private bool m_doNotRemoveIfTooLong = false;
private List<UpdateMethod> m_subscribers = new List<UpdateMethod>();
private float m_updateTimer = 0;
private int m_nextActiveSubIndex = 0; // For staggering subscribers, to prevent spikes of lots of things triggering at once.
public void Awake()
{
Locator.Get.Provide(this);
}
public void OnDestroy()
{
// We should clean up references in case they would prevent garbage collection.
m_subscribers.Clear();
}
/// <summary>Don't assume that onUpdate will be called in any particular order compared to other subscribers.</summary>
public void Subscribe(UpdateMethod onUpdate)
{
if (!m_subscribers.Contains(onUpdate))
m_subscribers.Add(onUpdate);
}
/// <summary>Safe to call even if onUpdate was not previously Subscribed.</summary>
public void Unsubscribe(UpdateMethod onUpdate)
{
int index = m_subscribers.IndexOf(onUpdate);
if (index >= 0)
{
m_subscribers.Remove(onUpdate);
if (index < m_nextActiveSubIndex)
m_nextActiveSubIndex--;
}
}
private void Update()
{
if (m_subscribers.Count == 0)
return;
m_updateTimer += Time.deltaTime;
float effectivePeriod = m_updatePeriod / m_subscribers.Count;
while (m_updateTimer > effectivePeriod)
{
m_updateTimer -= effectivePeriod;
OnUpdate(effectivePeriod);
}
}
public void OnUpdate(float dt)
{
Stopwatch stopwatch = new Stopwatch();
m_nextActiveSubIndex = System.Math.Max(0, System.Math.Min(m_subscribers.Count - 1, m_nextActiveSubIndex)); // Just a backup.
UpdateMethod onUpdate = m_subscribers[m_nextActiveSubIndex];
if (onUpdate == null || onUpdate.Target == null) // In case something forgets to Unsubscribe when it dies.
{ Remove(m_nextActiveSubIndex, $"Did not Unsubscribe from UpdateSlow: {onUpdate.Target} : {onUpdate.Method}");
return;
}
if (onUpdate.Method.ToString().Contains("<")) // Detect an anonymous or lambda or local method that cannot be Unsubscribed, by checking for a character that can't exist in a declared method name.
{ Remove(m_nextActiveSubIndex, $"Removed anonymous from UpdateSlow: {onUpdate.Target} : {onUpdate.Method}");
return;
}
stopwatch.Restart();
onUpdate?.Invoke(dt);
stopwatch.Stop();
if (stopwatch.ElapsedMilliseconds > m_durationToleranceMs)
{
if (!m_doNotRemoveIfTooLong)
Remove(m_nextActiveSubIndex, $"UpdateSlow subscriber took too long, removing: {onUpdate.Target} : {onUpdate.Method}");
else
{
Debug.LogWarning($"UpdateSlow subscriber took too long: {onUpdate.Target} : {onUpdate.Method}");
Increment();
}
}
else
Increment();
void Remove(int index, string msg)
{
m_subscribers.RemoveAt(index);
m_nextActiveSubIndex--;
Debug.LogError(msg);
Increment();
}
void Increment()
{
m_nextActiveSubIndex++;
if (m_nextActiveSubIndex >= m_subscribers.Count)
m_nextActiveSubIndex = 0;
}
}
public void OnReProvided(IUpdateSlow prevUpdateSlow)
{
if (prevUpdateSlow is UpdateSlow)
m_subscribers.AddRange((prevUpdateSlow as UpdateSlow).m_subscribers);
}
}
}

4
Assets/Scripts/Entities/LocalLobbyObserver.cs


namespace LobbyRelaySample
{
public class LocalLobbyObserver : ObserverBehaviour<LocalLobby> { }
}

130
Assets/Scripts/Tests/PlayMode/LobbyReadyCheckTests.cs


using LobbyRelaySample;
using NUnit.Framework;
using System.Collections;
using Unity.Services.Rooms;
using Unity.Services.Rooms.Models;
using UnityEngine;
using UnityEngine.TestTools;
using LobbyAPIInterface = LobbyRelaySample.Lobby.LobbyAPIInterface;
namespace Test
{
public class LobbyReadyCheckTests
{
private string m_workingLobbyId;
private LobbyRelaySample.Auth.Identity m_auth;
private bool m_didSigninComplete = false;
private GameObject m_updateSlowObj;
[OneTimeSetUp]
public void Setup()
{
m_auth = new LobbyRelaySample.Auth.Identity(() => { m_didSigninComplete = true; });
Locator.Get.Provide(m_auth);
m_updateSlowObj = new GameObject("UpdateSlowTest");
m_updateSlowObj.AddComponent<UpdateSlow>();
}
[UnityTearDown]
public IEnumerator PerTestTeardown()
{
if (m_workingLobbyId != null)
{ LobbyAPIInterface.DeleteLobbyAsync(m_workingLobbyId, null);
m_workingLobbyId = null;
}
yield return new WaitForSeconds(0.5f); // We need a yield anyway, so wait long enough to probably delete the lobby. There currently (6/22/2021) aren't other tests that would have issues if this took longer.
}
[OneTimeTearDown]
public void Teardown()
{
Locator.Get.Provide(new LobbyRelaySample.Auth.IdentityNoop());
m_auth.Dispose();
LogAssert.ignoreFailingMessages = false;
LobbyAsyncRequests.Instance.EndTracking();
GameObject.Destroy(m_updateSlowObj);
}
private IEnumerator WaitForSignin()
{
// Wait a reasonable amount of time for sign-in to complete.
if (!m_didSigninComplete)
yield return new WaitForSeconds(3);
if (!m_didSigninComplete)
Assert.Fail("Did not sign in.");
}
private IEnumerator CreateLobby(string lobbyName, string userId)
{
Response<Room> createResponse = null;
float timeout = 5;
LobbyAPIInterface.CreateLobbyAsync(userId, lobbyName, 4, false, (r) => { createResponse = r; });
while (createResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (lobby creation).");
m_workingLobbyId = createResponse.Result.Id;
}
private IEnumerator PushPlayerData(LobbyUser player)
{
bool hasPushedPlayerData = false;
float timeout = 5;
LobbyAsyncRequests.Instance.UpdatePlayerDataAsync(LobbyRelaySample.Lobby.ToLocalLobby.RetrieveUserData(player), () => { hasPushedPlayerData = true; }); // LobbyContentHeartbeat normally does this.
while (!hasPushedPlayerData && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (push player data).");
}
/// <summary>
/// After creating a lobby and a player, signal that the player is Ready. This should lead to a countdown time being set for all players.
/// </summary>
[UnityTest]
public IEnumerator SetCountdownTimeSinglePlayer()
{
LogAssert.ignoreFailingMessages = true; // Not sure why, but when auth logs in, it sometimes generates an error: "A Native Collection has not been disposed[...]." We don't want this to cause test failures, since in practice it *seems* to not negatively impact behavior.
ReadyCheck readyCheck = new ReadyCheck(5); // This ready time is used for the countdown target end, not for any of the timing of actually detecting readies.
yield return WaitForSignin();
string userId = m_auth.GetSubIdentity(LobbyRelaySample.Auth.IIdentityType.Auth).GetContent("id");
yield return CreateLobby("TestReadyLobby1", userId);
LobbyAsyncRequests.Instance.BeginTracking(m_workingLobbyId);
yield return new WaitForSeconds(2); // Allow the initial lobby retrieval.
LobbyUser user = new LobbyUser();
user.ID = userId;
user.UserStatus = UserStatus.Ready;
yield return PushPlayerData(user);
readyCheck.BeginCheckingForReady();
float timeout = 5; // Long enough for two slow updates
yield return new WaitForSeconds(timeout);
readyCheck.Dispose();
LobbyAsyncRequests.Instance.EndTracking();
yield return new WaitForSeconds(2); // Buffer to prevent a 429 on the upcoming Get, since there's a Get request on the slow upate loop when that's active.
Response<Room> getResponse = null;
timeout = 5;
LobbyAPIInterface.GetLobbyAsync(m_workingLobbyId, (r) => { getResponse = r; });
while (getResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (get lobby).");
Assert.NotNull(getResponse.Result, "Retrieved lobby successfully.");
Assert.NotNull(getResponse.Result.Data, "Lobby should have data.");
Assert.True(getResponse.Result.Data.ContainsKey("AllPlayersReady"), "Check for AllPlayersReady key.");
string readyString = getResponse.Result.Data["AllPlayersReady"]?.Value;
Assert.NotNull(readyString, "Check for non-null AllPlayersReady.");
Assert.True(long.TryParse(readyString, out long ticks), "Check for ticks value in AllPlayersReady."); // This will be based on the current time, so we won't check for a specific value.
}
// Can't test with multiple players on one machine, since anonymous UAS credentials can't be manually supplied.
}
}

163
Assets/Scripts/Tests/PlayMode/LobbyRoundtripTests.cs


using NUnit.Framework;
using System.Collections;
using System.Linq;
using Unity.Services.Rooms;
using Unity.Services.Rooms.Models;
using UnityEngine;
using UnityEngine.TestTools;
using LobbyAPIInterface = LobbyRelaySample.Lobby.LobbyAPIInterface;
namespace Test
{
/// <summary>
/// Hits the Authentication and Lobbies services in order to ensure lobbies can be created and deleted.
/// The actual code accessing lobbies should go through LobbyAsyncRequests.
/// </summary>
public class LobbyRoundtripTests
{
private string m_workingLobbyId;
private LobbyRelaySample.Auth.SubIdentity_Authentication m_auth;
private bool m_didSigninComplete = false;
[OneTimeSetUp]
public void Setup()
{
m_auth = new LobbyRelaySample.Auth.SubIdentity_Authentication(() => { m_didSigninComplete = true; });
}
[UnityTearDown]
public IEnumerator PerTestTeardown()
{
if (m_workingLobbyId != null)
{ LobbyAPIInterface.DeleteLobbyAsync(m_workingLobbyId, null);
m_workingLobbyId = null;
}
yield return new WaitForSeconds(0.5f); // We need a yield anyway, so wait long enough to probably delete the lobby. There currently (6/22/2021) aren't other tests that would have issues if this took longer.
}
[OneTimeTearDown]
public void Teardown()
{
m_auth?.Dispose();
LogAssert.ignoreFailingMessages = false;
}
[UnityTest]
public IEnumerator DoRoundtrip()
{
LogAssert.ignoreFailingMessages = true; // Not sure why, but when auth logs in, it sometimes generates an error: "A Native Collection has not been disposed[...]." We don't want this to cause test failures, since in practice it *seems* to not negatively impact behavior.
// Wait a reasonable amount of time for sign-in to complete.
if (!m_didSigninComplete)
yield return new WaitForSeconds(3);
if (!m_didSigninComplete)
Assert.Fail("Did not sign in.");
// Since we're signed in through the same pathway as the actual game, the list of lobbies will include any that have been made in the game itself, so we should account for those.
// If you want to get around this, consider having a secondary project using the same assets with its own credentials.
yield return new WaitForSeconds(1); // To prevent a possible 429 with the upcoming Query request, in case a previous test had one; Query requests can only occur at a rate of 1 per second.
Response<QueryResponse> queryResponse = null;
float timeout = 5;
LobbyAPIInterface.QueryAllLobbiesAsync((qr) => { queryResponse = qr; });
while (queryResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (query #0)");
Assert.IsTrue(queryResponse.Status >= 200 && queryResponse.Status < 300, "QueryAllLobbiesAsync should return a success code. (#0)");
int numLobbiesIni = queryResponse.Result.Results?.Count ?? 0;
// Create a test lobby.
Response<Room> createResponse = null;
timeout = 5;
string lobbyName = "TestLobby-JustATest-123";
LobbyAPIInterface.CreateLobbyAsync(m_auth.GetContent("id"), lobbyName, 100, false, (r) => { createResponse = r; });
while (createResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (create)");
Assert.IsTrue(createResponse.Status >= 200 && createResponse.Status < 300, "CreateLobbyAsync should return a success code.");
m_workingLobbyId = createResponse.Result.Id;
Assert.AreEqual(lobbyName, createResponse.Result.Name, "Created lobby should match the provided name.");
// Query for the test lobby via QueryAllLobbies.
yield return new WaitForSeconds(1); // To prevent a possible 429 with the upcoming Query request.
queryResponse = null;
timeout = 5;
LobbyAPIInterface.QueryAllLobbiesAsync((qr) => { queryResponse = qr; });
while (queryResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (query #1)");
Assert.IsTrue(queryResponse.Status >= 200 && queryResponse.Status < 300, "QueryAllLobbiesAsync should return a success code. (#1)");
Assert.AreEqual(1 + numLobbiesIni, queryResponse.Result.Results.Count, "Queried lobbies list should contain the test lobby.");
Assert.IsTrue(queryResponse.Result.Results.Where(r => r.Name == lobbyName).Count() == 1, "Checking queried lobby for name.");
Assert.IsTrue(queryResponse.Result.Results.Where(r => r.Id == m_workingLobbyId).Count() == 1, "Checking queried lobby for ID.");
// Query for solely the test lobby via GetLobby.
Response<Room> getResponse = null;
timeout = 5;
LobbyAPIInterface.GetLobbyAsync(createResponse.Result.Id, (r) => { getResponse = r; });
while (getResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (get)");
Assert.IsTrue(getResponse.Status >= 200 && getResponse.Status < 300, "GetLobbyAsync should return a success code.");
Assert.AreEqual(lobbyName, getResponse.Result.Name, "Checking the lobby we got for name.");
Assert.AreEqual(m_workingLobbyId, getResponse.Result.Id, "Checking the lobby we got for ID.");
// Delete the test lobby.
Response deleteResponse = null;
timeout = 5;
LobbyAPIInterface.DeleteLobbyAsync(m_workingLobbyId, (r) => { deleteResponse = r; });
while (deleteResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (delete)");
Assert.IsTrue(deleteResponse.Status >= 200 && deleteResponse.Status < 300, "DeleteLobbyAsync should return a success code.");
m_workingLobbyId = null;
// Query to ensure the lobby is gone.
yield return new WaitForSeconds(1); // To prevent a possible 429 with the upcoming Query request.
Response<QueryResponse> queryResponseTwo = null;
timeout = 5;
LobbyAPIInterface.QueryAllLobbiesAsync((qr) => { queryResponseTwo = qr; });
while (queryResponseTwo == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (query #2)");
Assert.IsTrue(queryResponseTwo.Status >= 200 && queryResponseTwo.Status < 300, "QueryAllLobbiesAsync should return a success code. (#2)");
Assert.AreEqual(numLobbiesIni, queryResponseTwo.Result.Results.Count, "Queried lobbies list should be empty.");
// Some error messages might be asynchronous, so to reduce spillover into other tests, just wait here for a bit before proceeding.
yield return new WaitForSeconds(3);
LogAssert.ignoreFailingMessages = false;
}
[UnityTest]
public IEnumerator OnCompletesOnFailure()
{
LogAssert.ignoreFailingMessages = true;
if (!m_didSigninComplete)
yield return new WaitForSeconds(3);
if (!m_didSigninComplete)
Assert.Fail("Did not sign in.");
bool? didComplete = null;
LobbyAPIInterface.CreateLobbyAsync("ThisStringIsInvalidHere", "lobby name", 123, false, (r) => { didComplete = (r == null); });
float timeout = 5;
while (didComplete == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check");
Assert.NotNull(didComplete, "Should have called onComplete, even if the async request failed.");
Assert.True(didComplete, "The returned object will be null, so expect to need to handle it.");
}
}
}

33
Assets/Scripts/UI/DisplayCodeUI.cs


using TMPro;
using UnityEngine;
namespace LobbyRelaySample.UI
{
/// <summary>
/// Watches a lobby or relay code for updates, displaying the current code to lobby members.
/// </summary>
public class DisplayCodeUI : ObserverPanel<LocalLobby>
{
public enum CodeType { Lobby = 0, Relay = 1 }
[SerializeField]
TMP_InputField m_outputText;
[SerializeField]
CodeType m_codeType;
public override void ObservedUpdated(LocalLobby observed)
{
string code = m_codeType == CodeType.Lobby ? observed.LobbyCode : observed.RelayCode;
if (!string.IsNullOrEmpty(code))
{
m_outputText.text = code;
Show();
}
else
{
Hide();
}
}
}
}

60
Assets/Scripts/UI/InLobbyUserList.cs


using System.Collections.Generic;
using UnityEngine;
namespace LobbyRelaySample.UI
{
/// <summary>
/// Contains the InLobbyUserUI instances while showing the UI for a lobby.
/// </summary>
[RequireComponent(typeof(LocalLobbyObserver))]
public class InLobbyUserList : ObserverPanel<LocalLobby>
{
[SerializeField]
List<InLobbyUserUI> m_UserUIObjects = new List<InLobbyUserUI>();
List<string> m_CurrentUsers = new List<string>(); // Just for keeping track more easily of which users are already displayed.
/// <summary>
/// When the observed data updates, we need to detect changes to the list of players.
/// </summary>
public override void ObservedUpdated(LocalLobby observed)
{
for (int id = m_CurrentUsers.Count - 1; id >= 0; id--) // We might remove users if they aren't in the new data, so iterate backwards.
{
string userId = m_CurrentUsers[id];
if (!observed.LobbyUsers.ContainsKey(userId))
{
foreach (var ui in m_UserUIObjects)
{
if (ui.UserId == userId)
{
ui.OnUserLeft();
OnUserLeft(userId);
}
}
}
}
foreach (var lobbyUserKvp in observed.LobbyUsers) // If there are new players, we need to hook them into the UI.
{
if (m_CurrentUsers.Contains(lobbyUserKvp.Key))
continue;
m_CurrentUsers.Add(lobbyUserKvp.Key);
foreach (var pcu in m_UserUIObjects)
{
if (pcu.IsAssigned)
continue;
pcu.SetUser(lobbyUserKvp.Value);
break;
}
}
}
void OnUserLeft(string userID)
{
if (!m_CurrentUsers.Contains(userID))
return;
m_CurrentUsers.Remove(userID);
}
}
}

19
Assets/Scripts/UI/LobbyNameUI.cs


using TMPro;
using UnityEngine;
namespace LobbyRelaySample.UI
{
/// <summary>
/// Displays the name of the lobby.
/// </summary>
public class LobbyNameUI : ObserverPanel<LocalLobby>
{
[SerializeField]
TMP_Text m_lobbyNameText;
public override void ObservedUpdated(LocalLobby observed)
{
m_lobbyNameText.SetText(observed.LobbyName);
}
}
}

19
Assets/Scripts/UI/RelayAddressUI.cs


using TMPro;
using UnityEngine;
namespace LobbyRelaySample.UI
{
/// <summary>
/// Displays the IP when connected to Relay.
/// </summary>
public class RelayAddressUI : ObserverPanel<LocalLobby>
{
[SerializeField]
TMP_Text m_IPAddressText;
public override void ObservedUpdated(LocalLobby observed)
{
m_IPAddressText.SetText(observed.RelayServer?.ToString());
}
}
}

22
Assets/Scripts/UI/ShowWhenLobbyStateUI.cs


using UnityEngine;
namespace LobbyRelaySample.UI
{
/// <summary>
/// UI element that is displayed when the lobby is in a particular state (e.g. counting down, in-game).
/// </summary>
public class ShowWhenLobbyStateUI : ObserverPanel<LocalLobby>
{
[SerializeField]
private LobbyState m_ShowThisWhen;
public override void ObservedUpdated(LocalLobby observed)
{
if (m_ShowThisWhen.HasFlag(observed.State))
Show();
else
Hide();
}
}
}

15
Assets/Scripts/UI/StartLobbyButtonUI.cs


using UnityEngine;
namespace LobbyRelaySample
{
/// <summary>
/// Main menu start button.
/// </summary>
public class StartLobbyButtonUI : MonoBehaviour
{
public void ToJoinMenu()
{
Locator.Get.Messenger.OnReceiveMessage(MessageType.ChangeGameState, GameState.JoinMenu);
}
}
}

108
Assets/Scripts/Lobby/LobbyAPIInterface.cs


using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Unity.Services.Rooms;
using Unity.Services.Rooms.Models;
using Unity.Services.Rooms.Rooms;
namespace LobbyRelaySample.Lobby
{
/// <summary>
/// Does all the interactions with the Lobby API.
/// </summary>
public static class LobbyAPIInterface
{
private class InProgressRequest<T>
{
public InProgressRequest(Task<T> task, Action<T> onComplete)
{
DoRequest(task, onComplete);
}
private async void DoRequest(Task<T> task, Action<T> onComplete)
{
T result = default;
string currentTrace = System.Environment.StackTrace;
try {
result = await task;
} catch (Exception e) {
Exception eFull = new Exception($"Call stack before async call:\n{currentTrace}\n", e);
throw eFull;
} finally {
onComplete?.Invoke(result);
}
}
}
private const int k_maxLobbiesToShow = 64;
public static void CreateLobbyAsync(string requesterUASId, string lobbyName, int maxPlayers, bool isPrivate, Action<Response<Room>> onComplete)
{
CreateRoomRequest createRequest = new CreateRoomRequest(new CreateRequest(
name: lobbyName,
player: new Unity.Services.Rooms.Models.Player(requesterUASId),
maxPlayers: maxPlayers,
isPrivate: isPrivate
));
var task = RoomsService.RoomsApiClient.CreateRoomAsync(createRequest);
new InProgressRequest<Response<Room>>(task, onComplete);
}
public static void DeleteLobbyAsync(string lobbyId, Action<Response> onComplete)
{
DeleteRoomRequest deleteRequest = new DeleteRoomRequest(lobbyId);
var task = RoomsService.RoomsApiClient.DeleteRoomAsync(deleteRequest);
new InProgressRequest<Response>(task, onComplete);
}
public static void JoinLobbyAsync(string requesterUASId, string lobbyId, string lobbyCode, Action<Response<Room>> onComplete)
{
JoinRoomRequest joinRequest = new JoinRoomRequest(new JoinRequest(
player: new Unity.Services.Rooms.Models.Player(requesterUASId),
id: lobbyId,
roomCode: lobbyCode
));
var task = RoomsService.RoomsApiClient.JoinRoomAsync(joinRequest);
new InProgressRequest<Response<Room>>(task, onComplete);
}
public static void LeaveLobbyAsync(string requesterUASId, string lobbyId, Action<Response> onComplete)
{
RemovePlayerRequest leaveRequest = new RemovePlayerRequest(lobbyId, requesterUASId);
var task = RoomsService.RoomsApiClient.RemovePlayerAsync(leaveRequest);
new InProgressRequest<Response>(task, onComplete);
}
public static void QueryAllLobbiesAsync(Action<Response<QueryResponse>> onComplete)
{
QueryRoomsRequest queryRequest = new QueryRoomsRequest(new QueryRequest(count: k_maxLobbiesToShow));
var task = RoomsService.RoomsApiClient.QueryRoomsAsync(queryRequest);
new InProgressRequest<Response<QueryResponse>>(task, onComplete);
}
public static void GetLobbyAsync(string lobbyId, Action<Response<Room>> onComplete)
{
GetRoomRequest getRequest = new GetRoomRequest(lobbyId);
var task = RoomsService.RoomsApiClient.GetRoomAsync(getRequest);
new InProgressRequest<Response<Room>>(task, onComplete);
}
public static void UpdateLobbyAsync(string lobbyId, Dictionary<string, DataObject> data, Action<Response<Room>> onComplete)
{
UpdateRoomRequest updateRequest = new UpdateRoomRequest(lobbyId, new UpdateRequest(
data: data
));
var task = RoomsService.RoomsApiClient.UpdateRoomAsync(updateRequest);
new InProgressRequest<Response<Room>>(task, onComplete);
}
public static void UpdatePlayerAsync(string lobbyId, string playerId, Dictionary<string, PlayerDataObject> data, Action<Response<Room>> onComplete)
{
UpdatePlayerRequest updateRequest = new UpdatePlayerRequest(lobbyId, playerId, new PlayerUpdateRequest(
data: data
));
var task = RoomsService.RoomsApiClient.UpdatePlayerAsync(updateRequest);
new InProgressRequest<Response<Room>>(task, onComplete);
}
}
}

218
Assets/Scripts/Lobby/LobbyAsyncRequests.cs


using LobbyRelaySample.Lobby;
using System;
using System.Collections.Generic;
using Unity.Services.Authentication;
using Unity.Services.Rooms;
using Unity.Services.Rooms.Models;
namespace LobbyRelaySample
{
/// <summary>
/// An abstraction layer between the direct calls into the Lobby API and the outcomes you actually want. E.g. you can request to get a readable list of
/// current lobbies and not need to make the query call directly.
/// </summary>
public class LobbyAsyncRequests
{
// Just doing a singleton since static access is all that's really necessary but we also need to be able to subscribe to the slow update loop.
private static LobbyAsyncRequests s_instance;
public static LobbyAsyncRequests Instance
{
get
{
if (s_instance == null)
s_instance = new LobbyAsyncRequests();
return s_instance;
}
}
public LobbyAsyncRequests()
{
Locator.Get.UpdateSlow.Subscribe(UpdateLobby); // Shouldn't need to unsubscribe since this instance won't be replaced.
}
private static bool IsSuccessful(Response response)
{
return response != null && response.Status >= 200 && response.Status < 300; // Uses HTTP status codes, so 2xx is a success.
}
#region We want to cache the lobby object so we don't query for it every time we need to do a different lobby operation or view current data.
// (This assumes that the player will be actively in just one lobby at a time, though they could passively be in more.)
private Queue<Action> m_pendingOperations = new Queue<Action>();
private string m_currentLobbyId = null;
private Room m_lastKnownLobby;
private bool m_isMidRetrieve = false;
public Room CurrentLobby => m_lastKnownLobby;
public void BeginTracking(string lobbyId)
{
m_currentLobbyId = lobbyId;
}
public void EndTracking()
{
m_currentLobbyId = null;
}
private void UpdateLobby(float unused)
{
if (!string.IsNullOrEmpty(m_currentLobbyId))
RetrieveLobbyAsync(m_currentLobbyId, OnComplete);
void OnComplete(Room lobby)
{
if (lobby != null)
m_lastKnownLobby = lobby;
m_isMidRetrieve = false;
HandlePendingOperations();
}
}
private void HandlePendingOperations()
{
while (m_pendingOperations.Count > 0)
m_pendingOperations.Dequeue()?.Invoke(); // Note: If this ends up enqueuing a bunch of operations, we might need to batch them and/or ensure they don't all execute at once.
}
#endregion
/// <summary>
/// Attempt to create a new lobby and then join it.
/// </summary>
public void CreateLobbyAsync(string lobbyName, int maxPlayers, bool isPrivate, Action<Room> onSuccess, Action onFailure)
{
string uasId = AuthenticationService.Instance.PlayerId;
LobbyAPIInterface.CreateLobbyAsync(uasId, lobbyName, maxPlayers, isPrivate, OnLobbyCreated);
void OnLobbyCreated(Response<Room> response)
{
if (!IsSuccessful(response))
onFailure?.Invoke();
else
{
var pendingLobby = response.Result;
onSuccess?.Invoke(pendingLobby); // The Create request automatically joins the lobby, so we need not take further action.
}
}
}
/// <summary>Attempt to join an existing lobby. Either ID xor code can be null.</summary>
public void JoinLobbyAsync(string lobbyId, string lobbyCode, Action<Room> onSuccess, Action onFailure)
{
string uasId = AuthenticationService.Instance.PlayerId;
LobbyAPIInterface.JoinLobbyAsync(uasId, lobbyId, lobbyCode, OnLobbyJoined);
void OnLobbyJoined(Response<Room> response)
{
if (!IsSuccessful(response))
onFailure?.Invoke();
else
onSuccess?.Invoke(response?.Result);
}
}
/// <summary>Used for getting the list of all active lobbies, without needing full info for each.</summary>
/// <param name="onListRetrieved">If called with null, retrieval was unsuccessful. Else, this will be given a list of contents to display, as pairs of a lobby code and a display string for that lobby.</param>
public void RetrieveLobbyListAsync(Action<QueryResponse> onListRetrieved, Action<Response<QueryResponse>> onError = null)
{
LobbyAPIInterface.QueryAllLobbiesAsync(OnLobbyListRetrieved);
void OnLobbyListRetrieved(Response<QueryResponse> response)
{
if (IsSuccessful(response))
onListRetrieved?.Invoke(response?.Result);
else
onError?.Invoke(response);
}
}
/// <param name="onComplete">If no lobby is retrieved, this is given null.</param>
private void RetrieveLobbyAsync(string lobbyId, Action<Room> onComplete)
{
if (m_isMidRetrieve)
return; // Not calling onComplete since there's just the one point at which this is called.
m_isMidRetrieve = true;
LobbyAPIInterface.GetLobbyAsync(lobbyId, OnGet);
void OnGet(Response<Room> response)
{
m_isMidRetrieve = false;
onComplete?.Invoke(response?.Result);
}
}
/// <summary>
/// Attempt to leave a lobby, and then delete it if no players remain.
/// </summary>
/// <param name="onComplete">Called once the request completes, regardless of success or failure.</param>
public void LeaveLobbyAsync(string lobbyId, Action onComplete)
{
string uasId = AuthenticationService.Instance.PlayerId;
LobbyAPIInterface.LeaveLobbyAsync(uasId, lobbyId, OnLeftLobby);
void OnLeftLobby(Response response)
{
onComplete?.Invoke();
// Lobbies will automatically delete the lobby if unoccupied, so we don't need to take further action.
// TEMP. As of 6/31/21, the lobbies service doesn't automatically delete emptied lobbies, though that functionality is expected in the near-term.
// Until then, we'll do a delete request whenever we leave, and if it's invalid, we'll just get a 403 back.
LobbyAPIInterface.DeleteLobbyAsync(lobbyId, null);
}
}
/// <param name="data">Key-value pairs, which will overwrite any existing data for these keys. Presumed to be available to all lobby members but not publicly.</param>
public void UpdatePlayerDataAsync(Dictionary<string, string> data, Action onComplete)
{
if (!ShouldUpdateData(() => { UpdatePlayerDataAsync(data, onComplete); }, onComplete))
return;
Room lobby = m_lastKnownLobby;
Dictionary<string, PlayerDataObject> dataCurr = new Dictionary<string, PlayerDataObject>();
foreach (var dataNew in data)
{
PlayerDataObject dataObj = new PlayerDataObject(visibility: PlayerDataObject.VisibilityOptions.Member, value: dataNew.Value);
if (dataCurr.ContainsKey(dataNew.Key))
dataCurr[dataNew.Key] = dataObj;
else
dataCurr.Add(dataNew.Key, dataObj);
}
LobbyAPIInterface.UpdatePlayerAsync(lobby.Id, Locator.Get.Identity.GetSubIdentity(Auth.IIdentityType.Auth).GetContent("id"), dataCurr, (r) => { onComplete?.Invoke(); });
}
/// <param name="data">Key-value pairs, which will overwrite any existing data for these keys. Presumed to be available to all lobby members but not publicly.</param>
public void UpdateLobbyDataAsync(Dictionary<string, string> data, Action onComplete)
{
if (!ShouldUpdateData(() => { UpdateLobbyDataAsync(data, onComplete); }, onComplete))
return;
Room lobby = m_lastKnownLobby;
Dictionary<string, DataObject> dataCurr = lobby.Data ?? new Dictionary<string, DataObject>();
foreach (var dataNew in data)
{
DataObject dataObj = new DataObject(visibility: DataObject.VisibilityOptions.Public, value: dataNew.Value); // Public so that when we request the list of lobbies, we can get info about them for filtering.
if (dataCurr.ContainsKey(dataNew.Key))
dataCurr[dataNew.Key] = dataObj;
else
dataCurr.Add(dataNew.Key, dataObj);
}
LobbyAPIInterface.UpdateLobbyAsync(lobby.Id, dataCurr, (r) => { onComplete?.Invoke(); });
}
private bool ShouldUpdateData(Action caller, Action onComplete)
{
if (m_isMidRetrieve)
{ m_pendingOperations.Enqueue(caller);
return false;
}
Room lobby = m_lastKnownLobby;
if (lobby == null)
{ onComplete?.Invoke();
return false;
}
return true;
}
}
}

109
Assets/Scripts/Lobby/LobbyContentHeartbeat.cs


using System;
using LobbyRemote = Unity.Services.Rooms.Models.Room;
namespace LobbyRelaySample
{
/// <summary>
/// Keep updated on changes to a joined lobby.
/// </summary>
public class LobbyContentHeartbeat
{
private LocalLobby m_localLobby;
private LobbyUser m_localUser;
private bool m_isAwaitingQuery = false;
private bool m_shouldPushData = false;
public void BeginTracking(LocalLobby lobby, LobbyUser localUser)
{
m_localLobby = lobby;
m_localUser = localUser;
Locator.Get.UpdateSlow.Subscribe(OnUpdate);
m_localLobby.onChanged += OnLocalLobbyChanged;
m_shouldPushData = true; // Ensure the initial presence of a new player is pushed to the lobby; otherwise, when a non-host joins, the LocalLobby never receives their data until they push something new.
}
public void EndTracking()
{
m_shouldPushData = false;
Locator.Get.UpdateSlow.Unsubscribe(OnUpdate);
if (m_localLobby != null)
m_localLobby.onChanged -= OnLocalLobbyChanged;
m_localLobby = null;
m_localUser = null;
}
private void OnLocalLobbyChanged(LocalLobby changed)
{
if (string.IsNullOrEmpty(changed.LobbyID)) // When the player leaves, their LocalLobby is cleared out but maintained.
EndTracking();
m_shouldPushData = true;
}
public void OnUpdate(float dt)
{
if (m_isAwaitingQuery || m_localLobby == null)
return;
m_isAwaitingQuery = true; // Note that because we make async calls, if one of them fails and doesn't call our callback, this will never be reset to false.
if (m_shouldPushData)
PushDataToLobby();
else
OnRetrieve();
void PushDataToLobby()
{
if (m_localUser == null)
{
m_isAwaitingQuery = false;
return; // Don't revert m_shouldPushData yet, so that we can retry.
}
m_shouldPushData = false;
if (m_localUser.IsHost)
DoLobbyDataPush();
else
DoPlayerDataPush();
}
void DoLobbyDataPush()
{
LobbyAsyncRequests.Instance.UpdateLobbyDataAsync(Lobby.ToLocalLobby.RetrieveLobbyData(m_localLobby), () => { DoPlayerDataPush(); });
}
void DoPlayerDataPush()
{
LobbyAsyncRequests.Instance.UpdatePlayerDataAsync(Lobby.ToLocalLobby.RetrieveUserData(m_localUser), () => { m_isAwaitingQuery = false; });
}
void OnRetrieve()
{
m_isAwaitingQuery = false;
LobbyRemote lobby = LobbyAsyncRequests.Instance.CurrentLobby;
if (lobby == null) return;
bool prevShouldPush = m_shouldPushData;
var prevState = m_localLobby.State;
Lobby.ToLocalLobby.Convert(lobby, m_localLobby, m_localUser);
m_shouldPushData = prevShouldPush;
CheckForAllPlayersReady();
if (prevState != LobbyState.Lobby && m_localLobby.State == LobbyState.Lobby)
Locator.Get.Messenger.OnReceiveMessage(MessageType.ToLobby, null);
}
void CheckForAllPlayersReady()
{
bool areAllPlayersReady = m_localLobby.AllPlayersReadyTime != null;
if (areAllPlayersReady)
{
long targetTimeTicks = m_localLobby.AllPlayersReadyTime.Value;
DateTime targetTime = new DateTime(targetTimeTicks);
if (targetTime.Subtract(DateTime.Now).Seconds < 0)
return;
Locator.Get.Messenger.OnReceiveMessage(MessageType.Client_EndReadyCountdownAt, targetTime); // Note that this could be called multiple times.
}
}
}
}
}

23
Assets/Scripts/Lobby/LobbyListHeartbeat.cs


using UnityEngine;
namespace LobbyRelaySample
{
/// <summary>
/// Keeps the lobby list updated automatically.
/// </summary>
public class LobbyListHeartbeat : MonoBehaviour
{
public void SetActive(bool isActive)
{
if (isActive)
Locator.Get.UpdateSlow.Subscribe(OnUpdate);
else
Locator.Get.UpdateSlow.Unsubscribe(OnUpdate);
}
private void OnUpdate(float dt)
{
Locator.Get.Messenger.OnReceiveMessage(MessageType.QueryLobbies, null);
}
}
}

63
Assets/Scripts/Lobby/ReadyCheck.cs


using System;
using System.Collections.Generic;
using System.Linq;
namespace LobbyRelaySample
{
/// <summary>
/// On the host, this will watch for all players to ready, and once they have, it will prepare for a synchronized countdown.
/// </summary>
public class ReadyCheck : IDisposable
{
float m_ReadyTime = 5;
public ReadyCheck(float readyTime = 5)
{
m_ReadyTime = readyTime;
}
public void BeginCheckingForReady()
{
Locator.Get.UpdateSlow.Subscribe(OnUpdate);
}
public void EndCheckingForReady()
{
Locator.Get.UpdateSlow.Unsubscribe(OnUpdate);
}
/// <summary>
/// Checks the lobby to see if we have all Readied up. If so, send out a message with the target time at which to end a countdown.
/// </summary>
void OnUpdate(float dt)
{
var lobby = LobbyAsyncRequests.Instance.CurrentLobby;
if (lobby == null || lobby.Players.Count == 0)
return;
int readyCount = lobby.Players.Count((p) =>
{
if (p.Data?.ContainsKey("UserStatus") != true) // Needs to be "!= true" to handle null properly.
return false;
UserStatus status;
if (Enum.TryParse(p.Data["UserStatus"].Value, out status))
return status == UserStatus.Ready;
return false;
});
if (readyCount == lobby.Players.Count)
{
Dictionary<string, string> data = new Dictionary<string, string>();
DateTime targetTime = DateTime.Now.AddSeconds(m_ReadyTime);
data.Add("AllPlayersReady", targetTime.Ticks.ToString());
LobbyAsyncRequests.Instance.UpdateLobbyDataAsync(data, null);
EndCheckingForReady();
}
}
public void Dispose()
{
EndCheckingForReady();
}
}
}

89
Assets/Scripts/Lobby/ToLocalLobby.cs


using System.Collections.Generic;
using Unity.Services.Rooms.Models;
namespace LobbyRelaySample.Lobby
{
/// <summary>
/// Convert the lobby resulting from a request into a LocalLobby for use in the game logic.
/// </summary>
public static class ToLocalLobby
{
/// <summary>
/// Create a new LocalLobby from the content of a retrieved lobby. Its data can be copied into an existing LocalLobby for use.
/// </summary>
public static void Convert(Room lobby, LocalLobby outputToHere, LobbyUser existingLocalUser = null)
{
LobbyInfo info = new LobbyInfo
{ LobbyID = lobby.Id,
LobbyCode = lobby.RoomCode,
Private = lobby.IsPrivate,
LobbyName = lobby.Name,
MaxPlayerCount = lobby.MaxPlayers,
RelayCode = lobby.Data?.ContainsKey("RelayCode") == true ? lobby.Data["RelayCode"].Value : null,
State = lobby.Data?.ContainsKey("State") == true ? (LobbyState) int.Parse(lobby.Data["State"].Value) : LobbyState.Lobby,
AllPlayersReadyTime = lobby.Data?.ContainsKey("AllPlayersReady") == true ? long.Parse(lobby.Data["AllPlayersReady"].Value) : (long?)null
};
Dictionary<string, LobbyUser> lobbyUsers = new Dictionary<string, LobbyUser>();
foreach (var player in lobby.Players)
{
if (existingLocalUser != null && player.Id.Equals(existingLocalUser.ID))
{
existingLocalUser.IsHost = lobby.HostId.Equals(player.Id);
existingLocalUser.DisplayName = player.Data?.ContainsKey("DisplayName") == true ? player.Data["DisplayName"].Value : existingLocalUser.DisplayName;
existingLocalUser.Emote = player.Data?.ContainsKey("Emote") == true ? player.Data["Emote"].Value : existingLocalUser.Emote;
lobbyUsers.Add(existingLocalUser.ID, existingLocalUser);
}
else
{
LobbyUser user = new LobbyUser(
displayName: player.Data?.ContainsKey("DisplayName") == true ? player.Data["DisplayName"].Value : "NewPlayer",
isHost: lobby.HostId.Equals(player.Id),
id: player.Id,
emote: player.Data?.ContainsKey("Emote") == true ? player.Data["Emote"].Value : null,
userStatus: player.Data?.ContainsKey("UserStatus") == true ? player.Data["UserStatus"].Value : UserStatus.Lobby.ToString()
);
lobbyUsers.Add(user.ID, user);
}
}
outputToHere.CopyObserved(info, lobbyUsers);
}
/// <summary>
/// Create a list of new LocalLobby from the content of a retrieved lobby.
/// </summary>
public static List<LocalLobby> Convert(QueryResponse response)
{
List<LocalLobby> retLst = new List<LocalLobby>();
foreach (var lobby in response.Results)
retLst.Add(Convert(lobby));
return retLst;
}
private static LocalLobby Convert(Room lobby)
{
LocalLobby data = new LocalLobby();
Convert(lobby, data, null);
return data;
}
public static Dictionary<string, string> RetrieveLobbyData(LocalLobby lobby)
{
Dictionary<string, string> data = new Dictionary<string, string>();
data.Add("RelayCode", lobby.RelayCode);
data.Add("State", ((int)lobby.State).ToString());
// We only want the ArePlayersReadyTime to be set when we actually are ready for it, and it's null otherwise. So, don't set that here.
return data;
}
public static Dictionary<string, string> RetrieveUserData(LobbyUser user)
{
Dictionary<string, string> data = new Dictionary<string, string>();
if (user == null || string.IsNullOrEmpty(user.ID))
return data;
data.Add("DisplayName", user.DisplayName);
data.Add("Emote", user.Emote); // Emote could be null, which is fine.
data.Add("UserStatus", user.UserStatus.ToString());
return data;
}
}
}

4
Assets/Scripts/Entities/LobbyDataObserver.cs


namespace LobbyRelaySample
{
public class LobbyDataObserver : ObserverBehaviour<LobbyData> { }
}

67
Assets/Scripts/Entities/LobbyReadyCheck.cs


using System;
using System.Collections.Generic;
using System.Linq;
namespace LobbyRelaySample
{
/// <summary>
/// On the host, this will watch for all players to ready, and once they have, it will prepare for a synchronized countdown.
/// </summary>
public class LobbyReadyCheck : IDisposable
{
Action<bool> m_OnReadyCheckComplete;
float m_ReadyTime = 5;
public LobbyReadyCheck(Action<bool> onReadyCheckComplete = null, float readyTime = 5)
{
m_OnReadyCheckComplete = onReadyCheckComplete;
m_ReadyTime = readyTime;
}
public void BeginCheckingForReady()
{
Locator.Get.UpdateSlow.Subscribe(OnUpdate);
}
public void EndCheckingForReady()
{
Locator.Get.UpdateSlow.Unsubscribe(OnUpdate);
}
/// <summary>
/// Checks the lobby to see if we have all Readied up. If so, send out a message with the target time at which to end a countdown.
/// </summary>
void OnUpdate(float dt)
{
var room = RoomsQuery.Instance.CurrentRoom;
if (room == null || room.Players.Count == 0)
return;
int readyCount = room.Players.Count((p) =>
{
if (p.Data?.ContainsKey("UserStatus") != true) // Needs to be "!= true" to handle null properly.
return false;
UserStatus status;
if (Enum.TryParse(p.Data["UserStatus"].Value, out status))
return status == UserStatus.Ready;
return false;
});
if (readyCount == room.Players.Count)
{
Dictionary<string, string> data = new Dictionary<string, string>();
DateTime targetTime = DateTime.Now.AddSeconds(m_ReadyTime);
data.Add("AllPlayersReady", targetTime.Ticks.ToString());
RoomsQuery.Instance.UpdateRoomDataAsync(data, null);
EndCheckingForReady();
}
}
public void Dispose()
{
EndCheckingForReady();
}
}
}

131
Assets/Scripts/Tests/PlayMode/ReadyCheckTests.cs


using LobbyRelaySample;
using NUnit.Framework;
using System.Collections;
using Unity.Services.Rooms;
using Unity.Services.Rooms.Models;
using UnityEngine;
using UnityEngine.TestTools;
using RoomsInterface = LobbyRelaySample.Lobby.RoomsInterface;
namespace Test
{
public class ReadyCheckTests
{
private string m_workingRoomId;
private LobbyRelaySample.Auth.Identity m_auth;
private bool m_didSigninComplete = false;
private GameObject m_updateSlowObj;
[OneTimeSetUp]
public void Setup()
{
m_auth = new LobbyRelaySample.Auth.Identity(() => { m_didSigninComplete = true; });
Locator.Get.Provide(m_auth);
m_updateSlowObj = new GameObject("UpdateSlowTest");
m_updateSlowObj.AddComponent<UpdateSlow>();
}
[UnityTearDown]
public IEnumerator PerTestTeardown()
{
if (m_workingRoomId != null)
{ RoomsInterface.DeleteRoomAsync(m_workingRoomId, null);
m_workingRoomId = null;
}
yield return new WaitForSeconds(0.5f); // We need a yield anyway, so wait long enough to probably delete the room. There currently (6/22/2021) aren't other tests that would have issues if this took longer.
}
[OneTimeTearDown]
public void Teardown()
{
Locator.Get.Provide(new LobbyRelaySample.Auth.IdentityNoop());
m_auth.Dispose();
LogAssert.ignoreFailingMessages = false;
RoomsQuery.Instance.EndTracking();
GameObject.Destroy(m_updateSlowObj);
}
private IEnumerator WaitForSignin()
{
// Wait a reasonable amount of time for sign-in to complete.
if (!m_didSigninComplete)
yield return new WaitForSeconds(3);
if (!m_didSigninComplete)
Assert.Fail("Did not sign in.");
}
private IEnumerator CreateRoom(string roomName, string userId)
{
Response<Room> createResponse = null;
float timeout = 5;
RoomsInterface.CreateRoomAsync(userId, roomName, 4, false, (r) => { createResponse = r; });
while (createResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (room creation).");
m_workingRoomId = createResponse.Result.Id;
}
private IEnumerator PushPlayerData(LobbyUser player)
{
bool hasPushedPlayerData = false;
float timeout = 5;
RoomsQuery.Instance.UpdatePlayerDataAsync(LobbyRelaySample.Lobby.ToLobbyData.RetrieveUserData(player), () => { hasPushedPlayerData = true; }); // RoomsContentHeartbeat normally does this.
while (!hasPushedPlayerData && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (push player data).");
}
/// <summary>
/// After creating a room and a player, signal that the player is Ready. This should lead to a countdown time being set for all players.
/// </summary>
[UnityTest]
public IEnumerator SetCountdownTimeSinglePlayer()
{
LogAssert.ignoreFailingMessages = true; // Not sure why, but when auth logs in, it sometimes generates an error: "A Native Collection has not been disposed[...]." We don't want this to cause test failures, since in practice it *seems* to not negatively impact behavior.
bool? readyResult = null;
LobbyReadyCheck readyCheck = new LobbyReadyCheck((b) => { readyResult = b; }, 5); // This ready time is used for the countdown target end, not for any of the timing of actually detecting readies.
yield return WaitForSignin();
string userId = m_auth.GetSubIdentity(LobbyRelaySample.Auth.IIdentityType.Auth).GetContent("id");
yield return CreateRoom("TestReadyRoom1", userId);
RoomsQuery.Instance.BeginTracking(m_workingRoomId);
yield return new WaitForSeconds(2); // Allow the initial room retrieval.
LobbyUser user = new LobbyUser();
user.ID = userId;
user.UserStatus = UserStatus.Ready;
yield return PushPlayerData(user);
readyCheck.BeginCheckingForReady();
float timeout = 5; // Long enough for two slow updates
yield return new WaitForSeconds(timeout);
readyCheck.Dispose();
RoomsQuery.Instance.EndTracking();
yield return new WaitForSeconds(2); // Buffer to prevent a 429 on the upcoming Get, since there's a Get request on the slow upate loop when that's active.
Response<Room> getResponse = null;
timeout = 5;
RoomsInterface.GetRoomAsync(m_workingRoomId, (r) => { getResponse = r; });
while (getResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (get room).");
Assert.NotNull(getResponse.Result, "Retrieved room successfully.");
Assert.NotNull(getResponse.Result.Data, "Room should have data.");
Assert.True(getResponse.Result.Data.ContainsKey("AllPlayersReady"), "Check for AllPlayersReady key.");
string readyString = getResponse.Result.Data["AllPlayersReady"]?.Value;
Assert.NotNull(readyString, "Check for non-null AllPlayersReady.");
Assert.True(long.TryParse(readyString, out long ticks), "Check for ticks value in AllPlayersReady."); // This will be based on the current time, so we won't check for a specific value.
}
// Can't test with multiple players on one machine, since anonymous UAS credentials can't be manually supplied.
}
}

163
Assets/Scripts/Tests/PlayMode/RoomsRoundtripTests.cs


using NUnit.Framework;
using System.Collections;
using System.Linq;
using Unity.Services.Rooms;
using Unity.Services.Rooms.Models;
using UnityEngine;
using UnityEngine.TestTools;
using RoomsInterface = LobbyRelaySample.Lobby.RoomsInterface;
namespace Test
{
/// <summary>
/// Hits the Authentication and Rooms services in order to ensure rooms can be created and deleted.
/// The actual code accessing rooms should go through RoomsQuery.
/// </summary>
public class RoomsRoundtripTests
{
private string m_workingRoomId;
private LobbyRelaySample.Auth.SubIdentity_Authentication m_auth;
private bool m_didSigninComplete = false;
[OneTimeSetUp]
public void Setup()
{
m_auth = new LobbyRelaySample.Auth.SubIdentity_Authentication(() => { m_didSigninComplete = true; });
}
[UnityTearDown]
public IEnumerator PerTestTeardown()
{
if (m_workingRoomId != null)
{ RoomsInterface.DeleteRoomAsync(m_workingRoomId, null);
m_workingRoomId = null;
}
yield return new WaitForSeconds(0.5f); // We need a yield anyway, so wait long enough to probably delete the room. There currently (6/22/2021) aren't other tests that would have issues if this took longer.
}
[OneTimeTearDown]
public void Teardown()
{
m_auth?.Dispose();
LogAssert.ignoreFailingMessages = false;
}
[UnityTest]
public IEnumerator DoRoundtrip()
{
LogAssert.ignoreFailingMessages = true; // Not sure why, but when auth logs in, it sometimes generates an error: "A Native Collection has not been disposed[...]." We don't want this to cause test failures, since in practice it *seems* to not negatively impact behavior.
// Wait a reasonable amount of time for sign-in to complete.
if (!m_didSigninComplete)
yield return new WaitForSeconds(3);
if (!m_didSigninComplete)
Assert.Fail("Did not sign in.");
// Since we're signed in through the same pathway as the actual game, the list of rooms will include any that have been made in the game itself, so we should account for those.
// If you want to get around this, consider having a secondary project using the same assets with its own credentials.
yield return new WaitForSeconds(1); // To prevent a possible 429 with the upcoming Query request, in case a previous test had one; Query requests can only occur at a rate of 1 per second.
Response<QueryResponse> queryResponse = null;
float timeout = 5;
RoomsInterface.QueryAllRoomsAsync((qr) => { queryResponse = qr; });
while (queryResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (query #0)");
Assert.IsTrue(queryResponse.Status >= 200 && queryResponse.Status < 300, "QueryAllRoomsAsync should return a success code. (#0)");
int numRoomsIni = queryResponse.Result.Results?.Count ?? 0;
// Create a test room.
Response<Room> createResponse = null;
timeout = 5;
string roomName = "TestRoom-JustATestRoom-123";
RoomsInterface.CreateRoomAsync(m_auth.GetContent("id"), roomName, 100, false, (r) => { createResponse = r; });
while (createResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (create)");
Assert.IsTrue(createResponse.Status >= 200 && createResponse.Status < 300, "CreateRoomAsync should return a success code.");
m_workingRoomId = createResponse.Result.Id;
Assert.AreEqual(roomName, createResponse.Result.Name, "Created room should match the provided name.");
// Query for the test room via QueryAllRooms.
yield return new WaitForSeconds(1); // To prevent a possible 429 with the upcoming Query request.
queryResponse = null;
timeout = 5;
RoomsInterface.QueryAllRoomsAsync((qr) => { queryResponse = qr; });
while (queryResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (query #1)");
Assert.IsTrue(queryResponse.Status >= 200 && queryResponse.Status < 300, "QueryAllRoomsAsync should return a success code. (#1)");
Assert.AreEqual(1 + numRoomsIni, queryResponse.Result.Results.Count, "Queried rooms list should contain the test room.");
Assert.IsTrue(queryResponse.Result.Results.Where(r => r.Name == roomName).Count() == 1, "Checking queried room for name.");
Assert.IsTrue(queryResponse.Result.Results.Where(r => r.Id == m_workingRoomId).Count() == 1, "Checking queried room for ID.");
// Query for solely the test room via GetRoom.
Response<Room> getResponse = null;
timeout = 5;
RoomsInterface.GetRoomAsync(createResponse.Result.Id, (r) => { getResponse = r; });
while (getResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (get)");
Assert.IsTrue(getResponse.Status >= 200 && getResponse.Status < 300, "GetRoomAsync should return a success code.");
Assert.AreEqual(roomName, getResponse.Result.Name, "Checking the room we got for name.");
Assert.AreEqual(m_workingRoomId, getResponse.Result.Id, "Checking the room we got for ID.");
// Delete the test room.
Response deleteResponse = null;
timeout = 5;
RoomsInterface.DeleteRoomAsync(m_workingRoomId, (r) => { deleteResponse = r; });
while (deleteResponse == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (delete)");
Assert.IsTrue(deleteResponse.Status >= 200 && deleteResponse.Status < 300, "DeleteRoomAsync should return a success code.");
m_workingRoomId = null;
// Query to ensure the room is gone.
yield return new WaitForSeconds(1); // To prevent a possible 429 with the upcoming Query request.
Response<QueryResponse> queryResponseTwo = null;
timeout = 5;
RoomsInterface.QueryAllRoomsAsync((qr) => { queryResponseTwo = qr; });
while (queryResponseTwo == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check (query #2)");
Assert.IsTrue(queryResponseTwo.Status >= 200 && queryResponseTwo.Status < 300, "QueryAllRoomsAsync should return a success code. (#2)");
Assert.AreEqual(numRoomsIni, queryResponseTwo.Result.Results.Count, "Queried rooms list should be empty.");
// Some error messages might be asynchronous, so to reduce spillover into other tests, just wait here for a bit before proceeding.
yield return new WaitForSeconds(3);
LogAssert.ignoreFailingMessages = false;
}
[UnityTest]
public IEnumerator OnCompletesOnFailure()
{
LogAssert.ignoreFailingMessages = true;
if (!m_didSigninComplete)
yield return new WaitForSeconds(3);
if (!m_didSigninComplete)
Assert.Fail("Did not sign in.");
bool? didComplete = null;
RoomsInterface.CreateRoomAsync("ThisStringIsInvalidHere", "room name", 123, false, (r) => { didComplete = (r == null); });
float timeout = 5;
while (didComplete == null && timeout > 0)
{ yield return new WaitForSeconds(0.25f);
timeout -= 0.25f;
}
Assert.Greater(timeout, 0, "Timeout check");
Assert.NotNull(didComplete, "Should have called onComplete, even if the async request failed.");
Assert.True(didComplete, "The returned object will be null, so expect to need to handle it.");
}
}
}

11
Assets/Scripts/UI/RelayCodeUI.cs.meta


fileFormatVersion: 2
guid: 9a70bc1b6f999ee43974bbbf991a0b6a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

5
Assets/Scripts/UI/SerializedValueEvents.cs


using UnityEngine;
using UnityEngine.Events;
[System.Serializable]
public class StringEvent : UnityEvent<string> { }

11
Assets/Scripts/UI/SerializedValueEvents.cs.meta


fileFormatVersion: 2
guid: 19bfe59f0f446ac4ea431b38580a52cf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

19
Assets/Scripts/UI/LobbyStateVisibilityUI.cs


using UnityEngine;
namespace LobbyRelaySample.UI
{
public class LobbyStateVisibilityUI : ObserverPanel<LobbyData>
{
[SerializeField]
private LobbyState m_ShowThisWhen;
public override void ObservedUpdated(LobbyData observed)
{
if (m_ShowThisWhen.HasFlag(observed.State))
Show();
else
Hide();
}
}
}

60
Assets/Scripts/UI/LobbyUsersUI.cs


using System.Collections.Generic;
using UnityEngine;
namespace LobbyRelaySample.UI
{
/// <summary>
/// Watches for changes in the Lobby's player List
/// </summary>
[RequireComponent(typeof(LobbyDataObserver))]
public class LobbyUsersUI : ObserverPanel<LobbyData>
{
[SerializeField]
List<LobbyUserCardUI> m_PlayerCardSlots = new List<LobbyUserCardUI>();
List<string> m_CurrentUsers = new List<string>(); // Just for keeping track more easily of which users are already displayed.
/// <summary>
/// When the observed data updates, we need to detect changes to the list of players.
/// </summary>
public override void ObservedUpdated(LobbyData observed)
{
for (int id = m_CurrentUsers.Count - 1; id >= 0; id--) // We might remove users if they aren't in the new data, so iterate backwards.
{
string userId = m_CurrentUsers[id];
if (!observed.LobbyUsers.ContainsKey(userId))
{
foreach (var card in m_PlayerCardSlots)
{
if (card.UserId == userId)
{
card.OnUserLeft();
OnUserLeft(userId);
}
}
}
}
foreach (var lobbyUserKvp in observed.LobbyUsers) // If there are new players, we need to hook them into the UI.
{
if (m_CurrentUsers.Contains(lobbyUserKvp.Key))
continue;
m_CurrentUsers.Add(lobbyUserKvp.Key);
foreach (var pcu in m_PlayerCardSlots)
{
if (pcu.IsAssigned)
continue;
pcu.SetUser(lobbyUserKvp.Value);
break;
}
}
}
void OnUserLeft(string userID)
{
if (!m_CurrentUsers.Contains(userID))
return;
m_CurrentUsers.Remove(userID);
}
}
}

27
Assets/Scripts/UI/RelayCodeUI.cs


using TMPro;
using UnityEngine;
namespace LobbyRelaySample.UI
{
/// <summary>
/// Read Only input field (for copy/paste reasons) Watches for the changes in the lobby's Relay Code
/// </summary>
public class RelayCodeUI : ObserverPanel<LobbyData>
{
[SerializeField]
TMP_InputField relayCodeText;
public override void ObservedUpdated(LobbyData observed)
{
if (!string.IsNullOrEmpty(observed.RelayCode))
{
relayCodeText.text = observed.RelayCode;
Show();
}
else
{
Hide();
}
}
}
}

26
Assets/Scripts/UI/RoomCodeUI.cs


using TMPro;
namespace LobbyRelaySample.UI
{
/// <summary>
/// Read Only input field (for copy/paste reasons) Watches for the changes in the lobby's Room Code
/// </summary>
public class RoomCodeUI : ObserverPanel<LobbyData>
{
public TMP_InputField roomCodeText;
public override void ObservedUpdated(LobbyData observed)
{
if (!string.IsNullOrEmpty(observed.RoomCode))
{
roomCodeText.text = observed.RoomCode;
Show();
}
else
{
Hide();
}
}
}
}

16
Assets/Scripts/UI/ServerAddressUI.cs


using TMPro;
using UnityEngine;
namespace LobbyRelaySample.UI
{
public class ServerAddressUI : ObserverPanel<LobbyData>
{
[SerializeField]
TMP_Text m_IPAddressText;
public override void ObservedUpdated(LobbyData observed)
{
m_IPAddressText.SetText(observed.RelayServer?.ToString());
}
}
}

16
Assets/Scripts/UI/ServerNameUI.cs


using TMPro;
using UnityEngine;
namespace LobbyRelaySample.UI
{
public class ServerNameUI : ObserverPanel<LobbyData>
{
[SerializeField]
TMP_Text m_ServerNameText;
public override void ObservedUpdated(LobbyData observed)
{
m_ServerNameText.SetText(observed.LobbyName);
}
}
}

12
Assets/Scripts/UI/StartLobbyButton.cs


using UnityEngine;
namespace LobbyRelaySample
{
public class StartLobbyButton : MonoBehaviour
{
public void ToJoinMenu()
{
Locator.Get.Messenger.OnReceiveMessage(MessageType.ChangeGameState, GameState.JoinMenu);
}
}
}

8
Assets/Scripts/Utilities.meta


fileFormatVersion: 2
guid: 4e59a0c0ec4227344bcc4a4f79c8ac31
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

11
Assets/TempDeleteAllRooms.cs.meta


fileFormatVersion: 2
guid: 0e7b1d59375bdf94795f339fd9395761
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

33
Assets/TempDeleteAllRooms.cs


using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Temporary script to allow a user to delete all the rooms they've previously made, so we don't have orphaned empty rooms.
/// </summary>
public class TempDeleteAllRooms : MonoBehaviour
{
private Queue<Unity.Services.Rooms.Models.Room> m_pendingRooms;
public void OnButton()
{
LobbyRelaySample.Lobby.RoomsInterface.QueryAllRoomsAsync((qr) => { DoDeletes(qr); });
}
private void DoDeletes(Unity.Services.Rooms.Response<Unity.Services.Rooms.Models.QueryResponse> response)
{
if (response != null && response.Status >= 200 && response.Status < 300)
{
StartCoroutine(DeleteCoroutine(response.Result.Results));
}
}
private IEnumerator DeleteCoroutine(List<Unity.Services.Rooms.Models.Room> rooms)
{
foreach (var room in rooms)
{
LobbyRelaySample.Lobby.RoomsInterface.DeleteRoomAsync(room.Id, null); // The onComplete callback isn't called in some error cases, e.g. a 403 when we don't have permissions, so don't block on it.
yield return new WaitForSeconds(1); // We need to wait a little to avoid 429's, but we might not run an onComplete depending on how the delete call fails.
}
}
}

167
ProjectSettings/SceneTemplateSettings.json


{
"templatePinStates": [],
"dependencyTypeInfos": [
{
"userAdded": false,
"type": "UnityEngine.AnimationClip",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEditor.Animations.AnimatorController",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.AnimatorOverrideController",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEditor.Audio.AudioMixerController",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.ComputeShader",
"ignore": true,
"defaultInstantiationMode": 1,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.Cubemap",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.GameObject",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEditor.LightingDataAsset",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": false
},
{
"userAdded": false,
"type": "UnityEngine.LightingSettings",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.Material",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEditor.MonoScript",
"ignore": true,
"defaultInstantiationMode": 1,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.PhysicMaterial",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.PhysicsMaterial2D",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.Rendering.PostProcessing.PostProcessProfile",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.Rendering.PostProcessing.PostProcessResources",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.Rendering.VolumeProfile",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEditor.SceneAsset",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": false
},
{
"userAdded": false,
"type": "UnityEngine.Shader",
"ignore": true,
"defaultInstantiationMode": 1,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.ShaderVariantCollection",
"ignore": true,
"defaultInstantiationMode": 1,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.Texture",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.Texture2D",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
},
{
"userAdded": false,
"type": "UnityEngine.Timeline.TimelineAsset",
"ignore": false,
"defaultInstantiationMode": 0,
"supportsModification": true
}
],
"defaultDependencyTypeInfo": {
"userAdded": false,
"type": "<default_scene_template_dependencies>",
"ignore": false,
"defaultInstantiationMode": 1,
"supportsModification": true
},
"newSceneOverride": 0
}

16
ProjectSettings/BurstAotSettings_StandaloneWindows.json


{
"MonoBehaviour": {
"Version": 3,
"EnableBurstCompilation": true,
"EnableOptimisations": true,
"EnableSafetyChecks": false,
"EnableDebugInAllBuilds": false,
"UsePlatformSDKLinker": false,
"CpuMinTargetX32": 0,
"CpuMaxTargetX32": 0,
"CpuMinTargetX64": 0,
"CpuMaxTargetX64": 0,
"CpuTargetsX32": 6,
"CpuTargetsX64": 72
}
}

6
ProjectSettings/CommonBurstAotSettings.json


{
"MonoBehaviour": {
"Version": 3,
"DisabledWarnings": ""
}
}

/Assets/Scripts/Entities/LobbyDataObserver.cs.meta → /Assets/Scripts/Entities/LocalLobbyObserver.cs.meta

/Assets/Scripts/Entities/LobbyData.cs.meta → /Assets/Scripts/Entities/LocalLobby.cs.meta

/Assets/Scripts/Entities/LobbyData.cs → /Assets/Scripts/Entities/LocalLobby.cs

/Assets/Scripts/Tests/PlayMode/RoomsRoundtripTests.cs.meta → /Assets/Scripts/Tests/PlayMode/LobbyRoundtripTests.cs.meta

/Assets/Scripts/Tests/PlayMode/ReadyCheckTests.cs.meta → /Assets/Scripts/Tests/PlayMode/LobbyReadyCheckTests.cs.meta

/Assets/Scripts/UI/StartLobbyButton.cs.meta → /Assets/Scripts/UI/StartLobbyButtonUI.cs.meta

/Assets/Scripts/UI/LobbyStateVisibilityUI.cs.meta → /Assets/Scripts/UI/ShowWhenLobbyStateUI.cs.meta

部分文件因为文件数量过多而无法显示

正在加载...
取消
保存