(Feat) Add Network Body
This commit is contained in:
+29
-1
@@ -25,7 +25,11 @@ Context for current work:
|
|||||||
no "temporary" shortcuts; if something is worth doing, do it properly.
|
no "temporary" shortcuts; if something is worth doing, do it properly.
|
||||||
- **Multiplayer co-op is a hard constraint** — when touching any player, world or shared
|
- **Multiplayer co-op is a hard constraint** — when touching any player, world or shared
|
||||||
system, assume two+ players exist (see §3).
|
system, assume two+ players exist (see §3).
|
||||||
- **No player arms / viewmodel** — feedback comes from camera, audio and UI only.
|
- **Player body is split by viewpoint** — the **local owner** sees a **first-person arms
|
||||||
|
viewmodel** (his own hands/tools); **remote players** are drawn as a **full third-person
|
||||||
|
body**. Each client renders its own arms *and* everyone else's full body. The owner's own
|
||||||
|
third-person body is hidden from his first-person camera (owner-only vs remote-only gating
|
||||||
|
lives in `PlayerNetworkController` — see §3). HUD/audio feedback still applies on top.
|
||||||
- Animations use **DOTween** (tweens, not Animator) wherever possible.
|
- Animations use **DOTween** (tweens, not Animator) wherever possible.
|
||||||
- Shaders are **hand-written HLSL/ShaderLab** — do not propose Shader Graph.
|
- Shaders are **hand-written HLSL/ShaderLab** — do not propose Shader Graph.
|
||||||
|
|
||||||
@@ -86,6 +90,30 @@ player — get it from `PlayerInventory.Instance` / `PlayerStats.Instance` (owne
|
|||||||
singletons), **never** `FindObjectOfType<PlayerX>()`, which also matches disabled remote
|
singletons), **never** `FindObjectOfType<PlayerX>()`, which also matches disabled remote
|
||||||
puppets (their root GameObject stays active) and targets the wrong player.
|
puppets (their root GameObject stays active) and targets the wrong player.
|
||||||
|
|
||||||
|
### Visible body — first-person arms (owner) + third-person body (remotes)
|
||||||
|
|
||||||
|
Every player is drawn twice, split by viewpoint (see §1):
|
||||||
|
|
||||||
|
- **First-person arms** — the owner's own hands/tools, seen *only* by the owner. The arms mesh
|
||||||
|
goes in `PlayerNetworkController.ownerOnlyObjects` (hidden on remotes); it is **never**
|
||||||
|
networked because no one else renders it.
|
||||||
|
- **Third-person body** — the full character everyone else sees. It stays active on *all*
|
||||||
|
clients so its `Animator` keeps running. `PlayerBodyVisibility` (a `NetworkBehaviour` on the
|
||||||
|
player root) relayers the **owner's own** body onto a `LocalBody` layer that the owner's
|
||||||
|
first-person camera excludes from its culling mask — so the owner never sees his own body
|
||||||
|
clip the view, yet every remote still renders it. **Layers are local and never networked**,
|
||||||
|
so hiding your body from yourself does not hide it from anyone else.
|
||||||
|
|
||||||
|
**Animation is a bus consumer, replicated by `NetworkAnimator`.** `PlayerAnimatorDriver`
|
||||||
|
(`Player/Animation/`) drives an `Animator` purely from `PlayerEvents` (`VelocityChanged`,
|
||||||
|
`GroundedChanged`, `Jumped`, `AttackSwung`, `CrouchChanged`, …). The same component drives both
|
||||||
|
rigs from that one shared source, so arms and body stay in lockstep. Each instance is owner-only
|
||||||
|
local simulation → **add every `PlayerAnimatorDriver` to `ownerOnlyBehaviours`**. On the body,
|
||||||
|
a sibling FishNet **`NetworkAnimator`** replicates the owner's animation to remotes (whose driver
|
||||||
|
is disabled and whose body is animated by the replicated parameters instead). Need a new
|
||||||
|
animation cue? Add an event to `PlayerEvents` and have the driver subscribe — never poll another
|
||||||
|
system's internals.
|
||||||
|
|
||||||
### Networking a player system — the canonical pattern (mirror `PlayerInventory`/`PlayerStats`)
|
### Networking a player system — the canonical pattern (mirror `PlayerInventory`/`PlayerStats`)
|
||||||
|
|
||||||
- Make it a `NetworkBehaviour`. **No destructive `Awake` singleton guard** (it would destroy
|
- Make it a `NetworkBehaviour`. **No destructive `Awake` singleton guard** (it would destroy
|
||||||
|
|||||||
@@ -185,6 +185,15 @@
|
|||||||
"processors": "",
|
"processors": "",
|
||||||
"interactions": "",
|
"interactions": "",
|
||||||
"initialStateCheck": false
|
"initialStateCheck": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SecondaryUse",
|
||||||
|
"type": "Button",
|
||||||
|
"id": "c1d2e3f4-0001-4001-8001-000000000001",
|
||||||
|
"expectedControlType": "Button",
|
||||||
|
"processors": "",
|
||||||
|
"interactions": "",
|
||||||
|
"initialStateCheck": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"bindings": [
|
"bindings": [
|
||||||
@@ -419,6 +428,28 @@
|
|||||||
"isComposite": false,
|
"isComposite": false,
|
||||||
"isPartOfComposite": false
|
"isPartOfComposite": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"id": "c1d2e3f4-0002-4002-8002-000000000002",
|
||||||
|
"path": "<Mouse>/rightButton",
|
||||||
|
"interactions": "",
|
||||||
|
"processors": "",
|
||||||
|
"groups": ";Keyboard&Mouse",
|
||||||
|
"action": "SecondaryUse",
|
||||||
|
"isComposite": false,
|
||||||
|
"isPartOfComposite": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"id": "c1d2e3f4-0003-4003-8003-000000000003",
|
||||||
|
"path": "<Gamepad>/leftTrigger",
|
||||||
|
"interactions": "",
|
||||||
|
"processors": "",
|
||||||
|
"groups": ";Gamepad",
|
||||||
|
"action": "SecondaryUse",
|
||||||
|
"isComposite": false,
|
||||||
|
"isPartOfComposite": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "",
|
"name": "",
|
||||||
"id": "b1b01001-0001-0001-0001-000000000001",
|
"id": "b1b01001-0001-0001-0001-000000000001",
|
||||||
|
|||||||
@@ -430,6 +430,8 @@ GameObject:
|
|||||||
- component: {fileID: 4782095384616423459}
|
- component: {fileID: 4782095384616423459}
|
||||||
- component: {fileID: 1509526206074017255}
|
- component: {fileID: 1509526206074017255}
|
||||||
- component: {fileID: 1890885114087150018}
|
- component: {fileID: 1890885114087150018}
|
||||||
|
- component: {fileID: 656527942023048284}
|
||||||
|
- component: {fileID: 4969709554801488359}
|
||||||
m_Layer: 10
|
m_Layer: 10
|
||||||
m_Name: Player
|
m_Name: Player
|
||||||
m_TagString: Untagged
|
m_TagString: Untagged
|
||||||
@@ -904,8 +906,6 @@ MonoBehaviour:
|
|||||||
_addedNetworkObject: {fileID: -5889822588553870904}
|
_addedNetworkObject: {fileID: -5889822588553870904}
|
||||||
_networkObjectCache: {fileID: -5889822588553870904}
|
_networkObjectCache: {fileID: -5889822588553870904}
|
||||||
ownerOnlyBehaviours:
|
ownerOnlyBehaviours:
|
||||||
- {fileID: 0}
|
|
||||||
- {fileID: 0}
|
|
||||||
- {fileID: 9009865201504802574}
|
- {fileID: 9009865201504802574}
|
||||||
- {fileID: 6967422961657961542}
|
- {fileID: 6967422961657961542}
|
||||||
- {fileID: 8358435118901160354}
|
- {fileID: 8358435118901160354}
|
||||||
@@ -1035,7 +1035,39 @@ MonoBehaviour:
|
|||||||
nameCanvas: {fileID: 5566668569623061449}
|
nameCanvas: {fileID: 5566668569623061449}
|
||||||
nameLabel: {fileID: 7210073718532933207}
|
nameLabel: {fileID: 7210073718532933207}
|
||||||
faceCamera: 1
|
faceCamera: 1
|
||||||
flipHorizontal: 1
|
flipHorizontal: 0
|
||||||
|
--- !u!114 &656527942023048284
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 5167716622245296089}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 8809701d89694f5488cc03b300f8dc09, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::Ashwild.Network.SessionPresenceNotifier
|
||||||
|
_componentIndexCache: 255
|
||||||
|
_addedNetworkObject: {fileID: -5889822588553870904}
|
||||||
|
_networkObjectCache: {fileID: 0}
|
||||||
|
--- !u!114 &4969709554801488359
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 5167716622245296089}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 23848f2e7f7ecba4791aa89d5344b3d0, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::Ashwild.Network.PlayerBodyVisibility
|
||||||
|
_componentIndexCache: 255
|
||||||
|
_addedNetworkObject: {fileID: -5889822588553870904}
|
||||||
|
_networkObjectCache: {fileID: 0}
|
||||||
|
bodyRoot: {fileID: 0}
|
||||||
|
localBodyLayer: LocalBody
|
||||||
--- !u!1 &5457574158965716017
|
--- !u!1 &5457574158965716017
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ GameObject:
|
|||||||
- component: {fileID: 4529112889726762}
|
- component: {fileID: 4529112889726762}
|
||||||
- component: {fileID: 33852252603615026}
|
- component: {fileID: 33852252603615026}
|
||||||
- component: {fileID: 23994028819166706}
|
- component: {fileID: 23994028819166706}
|
||||||
- component: {fileID: 7418467708994617123}
|
- component: {fileID: 3553698819402378907}
|
||||||
m_Layer: 8
|
m_Layer: 8
|
||||||
m_Name: hammer01
|
m_Name: hammer01
|
||||||
m_TagString: Untagged
|
m_TagString: Untagged
|
||||||
@@ -91,7 +91,7 @@ MeshRenderer:
|
|||||||
m_SortingOrder: 0
|
m_SortingOrder: 0
|
||||||
m_MaskInteraction: 0
|
m_MaskInteraction: 0
|
||||||
m_AdditionalVertexStreams: {fileID: 0}
|
m_AdditionalVertexStreams: {fileID: 0}
|
||||||
--- !u!114 &7418467708994617123
|
--- !u!114 &3553698819402378907
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
@@ -100,13 +100,7 @@ MonoBehaviour:
|
|||||||
m_GameObject: {fileID: 1085285520790580}
|
m_GameObject: {fileID: 1085285520790580}
|
||||||
m_Enabled: 1
|
m_Enabled: 1
|
||||||
m_EditorHideFlags: 0
|
m_EditorHideFlags: 0
|
||||||
m_Script: {fileID: 11500000, guid: ccc8774893954fc40a153ddb5fdb1ffb, type: 3}
|
m_Script: {fileID: 11500000, guid: 59ffa82caa5b57841a87a6f1e5d8839d, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::ToolBehaviour
|
m_EditorClassIdentifier: Assembly-CSharp::Ashwild.Player.BuildHammerBehaviour
|
||||||
harvestRange: 3
|
toggleCooldown: 0.25
|
||||||
harvestMask:
|
|
||||||
serializedVersion: 2
|
|
||||||
m_Bits: 4294967295
|
|
||||||
baseDamage: 10
|
|
||||||
wrongToolMultiplier: 0.25
|
|
||||||
swingCooldown: 0.5
|
|
||||||
|
|||||||
@@ -3599,6 +3599,83 @@ MonoBehaviour:
|
|||||||
m_FillOrigin: 0
|
m_FillOrigin: 0
|
||||||
m_UseSpriteMesh: 0
|
m_UseSpriteMesh: 0
|
||||||
m_PixelsPerUnitMultiplier: 1
|
m_PixelsPerUnitMultiplier: 1
|
||||||
|
--- !u!1 &6341143146183240713
|
||||||
|
GameObject:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
serializedVersion: 6
|
||||||
|
m_Component:
|
||||||
|
- component: {fileID: 7525940299773702526}
|
||||||
|
- component: {fileID: 4806562339521625408}
|
||||||
|
- component: {fileID: 2447618472228548681}
|
||||||
|
m_Layer: 5
|
||||||
|
m_Name: Joined Text
|
||||||
|
m_TagString: Untagged
|
||||||
|
m_Icon: {fileID: 0}
|
||||||
|
m_NavMeshLayer: 0
|
||||||
|
m_StaticEditorFlags: 0
|
||||||
|
m_IsActive: 1
|
||||||
|
--- !u!224 &7525940299773702526
|
||||||
|
RectTransform:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 6341143146183240713}
|
||||||
|
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: 4072753262893566013}
|
||||||
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
|
m_AnchorMin: {x: 0, y: 0.5}
|
||||||
|
m_AnchorMax: {x: 0, y: 0.5}
|
||||||
|
m_AnchoredPosition: {x: 12.93, y: 0}
|
||||||
|
m_SizeDelta: {x: 391.2352, y: 0}
|
||||||
|
m_Pivot: {x: 0, y: 0.5}
|
||||||
|
--- !u!114 &4806562339521625408
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 6341143146183240713}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 59f8146938fff824cb5fd77236b75775, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.VerticalLayoutGroup
|
||||||
|
m_Padding:
|
||||||
|
m_Left: 0
|
||||||
|
m_Right: 0
|
||||||
|
m_Top: 0
|
||||||
|
m_Bottom: 0
|
||||||
|
m_ChildAlignment: 0
|
||||||
|
m_Spacing: 0
|
||||||
|
m_ChildForceExpandWidth: 1
|
||||||
|
m_ChildForceExpandHeight: 1
|
||||||
|
m_ChildControlWidth: 1
|
||||||
|
m_ChildControlHeight: 0
|
||||||
|
m_ChildScaleWidth: 0
|
||||||
|
m_ChildScaleHeight: 0
|
||||||
|
m_ReverseArrangement: 0
|
||||||
|
--- !u!114 &2447618472228548681
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 6341143146183240713}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 3245ec927659c4140ac4f8d17403cc18, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.ContentSizeFitter
|
||||||
|
m_HorizontalFit: 0
|
||||||
|
m_VerticalFit: 2
|
||||||
--- !u!1 &6600692384150205846
|
--- !u!1 &6600692384150205846
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -4673,6 +4750,7 @@ RectTransform:
|
|||||||
- {fileID: 5307559709507874296}
|
- {fileID: 5307559709507874296}
|
||||||
- {fileID: 1750987845964819782}
|
- {fileID: 1750987845964819782}
|
||||||
- {fileID: 7001037734087836054}
|
- {fileID: 7001037734087836054}
|
||||||
|
- {fileID: 7525940299773702526}
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 0}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
m_AnchorMin: {x: 0, y: 0}
|
m_AnchorMin: {x: 0, y: 0}
|
||||||
@@ -6521,7 +6599,7 @@ PrefabInstance:
|
|||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3}
|
- target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3}
|
||||||
propertyPath: m_LocalPosition.z
|
propertyPath: m_LocalPosition.z
|
||||||
value: 0.85136575
|
value: 0
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3}
|
- target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3}
|
||||||
propertyPath: m_LocalRotation.w
|
propertyPath: m_LocalRotation.w
|
||||||
@@ -6541,11 +6619,11 @@ PrefabInstance:
|
|||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3}
|
- target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3}
|
||||||
propertyPath: m_AnchoredPosition.x
|
propertyPath: m_AnchoredPosition.x
|
||||||
value: 271.68515
|
value: 0
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3}
|
- target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3}
|
||||||
propertyPath: m_AnchoredPosition.y
|
propertyPath: m_AnchoredPosition.y
|
||||||
value: -401.69788
|
value: 0
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3}
|
- target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3}
|
||||||
propertyPath: m_LocalEulerAnglesHint.x
|
propertyPath: m_LocalEulerAnglesHint.x
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ MonoBehaviour:
|
|||||||
highlightImage: {fileID: 9078470808336705615}
|
highlightImage: {fileID: 9078470808336705615}
|
||||||
usageBarRoot: {fileID: 5983078061427925415}
|
usageBarRoot: {fileID: 5983078061427925415}
|
||||||
usageBarFill: {fileID: 2465922160796796964}
|
usageBarFill: {fileID: 2465922160796796964}
|
||||||
normalTint: {r: 0.4862745, g: 0.9882354, b: 0, a: 1}
|
normalTint: {r: 1, g: 1, b: 1, a: 1}
|
||||||
depletedTint: {r: 1, g: 0, b: 0, a: 1}
|
depletedTint: {r: 1, g: 0, b: 0, a: 1}
|
||||||
--- !u!1 &8681194172915985056
|
--- !u!1 &8681194172915985056
|
||||||
GameObject:
|
GameObject:
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 468498af192084144bafa6de7497eef5
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
%YAML 1.1
|
||||||
|
%TAG !u! tag:unity3d.com,2011:
|
||||||
|
--- !u!1 &6775333764305028426
|
||||||
|
GameObject:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
serializedVersion: 6
|
||||||
|
m_Component:
|
||||||
|
- component: {fileID: 3290554450925749106}
|
||||||
|
- component: {fileID: 2449280836210487377}
|
||||||
|
- component: {fileID: 7747655896599209196}
|
||||||
|
- component: {fileID: 8885320429130867994}
|
||||||
|
- component: {fileID: 6270478011457871439}
|
||||||
|
m_Layer: 5
|
||||||
|
m_Name: PresenceFeedEntry
|
||||||
|
m_TagString: Untagged
|
||||||
|
m_Icon: {fileID: 0}
|
||||||
|
m_NavMeshLayer: 0
|
||||||
|
m_StaticEditorFlags: 0
|
||||||
|
m_IsActive: 1
|
||||||
|
--- !u!224 &3290554450925749106
|
||||||
|
RectTransform:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 6775333764305028426}
|
||||||
|
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: 0}
|
||||||
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
|
m_AnchorMin: {x: 0, y: 0}
|
||||||
|
m_AnchorMax: {x: 0, y: 0}
|
||||||
|
m_AnchoredPosition: {x: 0, y: 0}
|
||||||
|
m_SizeDelta: {x: 0, y: 50}
|
||||||
|
m_Pivot: {x: 0.5, y: 0.5}
|
||||||
|
--- !u!114 &2449280836210487377
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 6775333764305028426}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 4c87382f506a62e489d4ea7a432accac, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::Ashwild.UI.PresenceFeedEntry
|
||||||
|
label: {fileID: 8885320429130867994}
|
||||||
|
canvasGroup: {fileID: 6270478011457871439}
|
||||||
|
--- !u!222 &7747655896599209196
|
||||||
|
CanvasRenderer:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 6775333764305028426}
|
||||||
|
m_CullTransparentMesh: 1
|
||||||
|
--- !u!114 &8885320429130867994
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 6775333764305028426}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
|
||||||
|
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_text: New Text
|
||||||
|
m_isRightToLeft: 0
|
||||||
|
m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
|
||||||
|
m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
|
||||||
|
m_fontSharedMaterials: []
|
||||||
|
m_fontMaterial: {fileID: 0}
|
||||||
|
m_fontMaterials: []
|
||||||
|
m_fontColor32:
|
||||||
|
serializedVersion: 2
|
||||||
|
rgba: 4294967295
|
||||||
|
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
|
||||||
|
m_enableVertexGradient: 0
|
||||||
|
m_colorMode: 3
|
||||||
|
m_fontColorGradient:
|
||||||
|
topLeft: {r: 1, g: 1, b: 1, a: 1}
|
||||||
|
topRight: {r: 1, g: 1, b: 1, a: 1}
|
||||||
|
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
|
||||||
|
bottomRight: {r: 1, g: 1, b: 1, a: 1}
|
||||||
|
m_fontColorGradientPreset: {fileID: 0}
|
||||||
|
m_spriteAsset: {fileID: 0}
|
||||||
|
m_tintAllSprites: 0
|
||||||
|
m_StyleSheet: {fileID: 0}
|
||||||
|
m_TextStyleHashCode: -1183493901
|
||||||
|
m_overrideHtmlColors: 0
|
||||||
|
m_faceColor:
|
||||||
|
serializedVersion: 2
|
||||||
|
rgba: 4294967295
|
||||||
|
m_fontSize: 36
|
||||||
|
m_fontSizeBase: 36
|
||||||
|
m_fontWeight: 400
|
||||||
|
m_enableAutoSizing: 0
|
||||||
|
m_fontSizeMin: 18
|
||||||
|
m_fontSizeMax: 72
|
||||||
|
m_fontStyle: 0
|
||||||
|
m_HorizontalAlignment: 1
|
||||||
|
m_VerticalAlignment: 512
|
||||||
|
m_textAlignment: 65535
|
||||||
|
m_characterSpacing: 0
|
||||||
|
m_characterHorizontalScale: 1
|
||||||
|
m_wordSpacing: 0
|
||||||
|
m_lineSpacing: 0
|
||||||
|
m_lineSpacingMax: 0
|
||||||
|
m_paragraphSpacing: 0
|
||||||
|
m_charWidthMaxAdj: 0
|
||||||
|
m_TextWrappingMode: 1
|
||||||
|
m_wordWrappingRatios: 0.4
|
||||||
|
m_overflowMode: 0
|
||||||
|
m_linkedTextComponent: {fileID: 0}
|
||||||
|
parentLinkedComponent: {fileID: 0}
|
||||||
|
m_enableKerning: 0
|
||||||
|
m_ActiveFontFeatures: 6e72656b
|
||||||
|
m_enableExtraPadding: 0
|
||||||
|
checkPaddingRequired: 0
|
||||||
|
m_isRichText: 1
|
||||||
|
m_EmojiFallbackSupport: 1
|
||||||
|
m_parseCtrlCharacters: 1
|
||||||
|
m_isOrthographic: 1
|
||||||
|
m_isCullingEnabled: 0
|
||||||
|
m_horizontalMapping: 0
|
||||||
|
m_verticalMapping: 0
|
||||||
|
m_uvLineOffset: 0
|
||||||
|
m_geometrySortingOrder: 0
|
||||||
|
m_IsTextObjectScaleStatic: 0
|
||||||
|
m_VertexBufferAutoSizeReduction: 0
|
||||||
|
m_useMaxVisibleDescender: 1
|
||||||
|
m_pageToDisplay: 1
|
||||||
|
m_margin: {x: 0, y: 0, z: 0, w: 0}
|
||||||
|
m_isUsingLegacyAnimationComponent: 0
|
||||||
|
m_isVolumetricText: 0
|
||||||
|
m_hasFontAssetChanged: 0
|
||||||
|
m_baseMaterial: {fileID: 0}
|
||||||
|
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
|
||||||
|
--- !u!225 &6270478011457871439
|
||||||
|
CanvasGroup:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 6775333764305028426}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_Alpha: 0
|
||||||
|
m_Interactable: 1
|
||||||
|
m_BlocksRaycasts: 1
|
||||||
|
m_IgnoreParentGroups: 0
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: dc9aaa105205d6e48bb40dd907fef4fe
|
||||||
|
PrefabImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Binary file not shown.
@@ -38,6 +38,10 @@ namespace Ashwild.Network
|
|||||||
[Tooltip("The networked player prefab (NetworkObject + PlayerNetworkController).")]
|
[Tooltip("The networked player prefab (NetworkObject + PlayerNetworkController).")]
|
||||||
[SerializeField] private NetworkObject playerPrefab;
|
[SerializeField] private NetworkObject playerPrefab;
|
||||||
|
|
||||||
|
[Tooltip("Hard cap on total players in a room (host included). The transport is set to accept " +
|
||||||
|
"maxPlayers - 1 remote clients, and the Steam invite lobby mirrors this limit.")]
|
||||||
|
[SerializeField] private int maxPlayers = 6;
|
||||||
|
|
||||||
[Header("Debug")]
|
[Header("Debug")]
|
||||||
[Tooltip("Logs every host/join/connection/spawn step to the console.")]
|
[Tooltip("Logs every host/join/connection/spawn step to the console.")]
|
||||||
[SerializeField] private bool verboseLogging = true;
|
[SerializeField] private bool verboseLogging = true;
|
||||||
@@ -56,6 +60,12 @@ namespace Ashwild.Network
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string GameSceneName => gameSceneName;
|
public string GameSceneName => gameSceneName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hard cap on total players per room (host included). Single source of truth for the
|
||||||
|
/// player limit — the Steam invite lobby reads this to mirror the cap.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxPlayers => maxPlayers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cached FishNet manager resolved from the active scene.
|
/// Cached FishNet manager resolved from the active scene.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -262,6 +272,8 @@ namespace Ashwild.Network
|
|||||||
|
|
||||||
if (args.ConnectionState == LocalConnectionState.Started)
|
if (args.ConnectionState == LocalConnectionState.Started)
|
||||||
{
|
{
|
||||||
|
ApplyPlayerCap();
|
||||||
|
|
||||||
string code = GetLocalSteamCode();
|
string code = GetLocalSteamCode();
|
||||||
PlayerEvents.RaiseSessionStarted(code);
|
PlayerEvents.RaiseSessionStarted(code);
|
||||||
Log($"✅ Session créée ! Code à partager = {code}");
|
Log($"✅ Session créée ! Code à partager = {code}");
|
||||||
@@ -430,6 +442,20 @@ namespace Ashwild.Network
|
|||||||
PlayerEvents.RaiseMenuReady();
|
PlayerEvents.RaiseMenuReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Caps the transport at <see cref="maxPlayers"/> - 1 remote clients. Called once the server has
|
||||||
|
/// Started, not before: FishySteamworks re-applies its own serialized limit to the socket inside
|
||||||
|
/// StartConnection, so an earlier call would be clobbered. The host runs a local client that never
|
||||||
|
/// crosses the Steam socket, so the remote limit is one below the total headcount; the transport
|
||||||
|
/// then refuses the surplus connection outright.
|
||||||
|
/// </summary>
|
||||||
|
private void ApplyPlayerCap()
|
||||||
|
{
|
||||||
|
int remoteCap = Mathf.Max(0, maxPlayers - 1);
|
||||||
|
networkManager.TransportManager.Transport.SetMaximumClients(remoteCap);
|
||||||
|
Log($"Player cap set to {maxPlayers} (transport accepts {remoteCap} remote clients).");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads the gameplay scene as a global networked scene, replacing the menu.
|
/// Loads the gameplay scene as a global networked scene, replacing the menu.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using FishNet.Object;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Ashwild.Network
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Splits the player's visible body by viewpoint. The local owner's third-person body is
|
||||||
|
/// moved onto a dedicated "local body" layer that his own first-person camera excludes from
|
||||||
|
/// its culling mask, so he never sees his own torso/head clip through the view — while every
|
||||||
|
/// remote client still renders that same body normally. The layer change is purely local and
|
||||||
|
/// never travels over the network, so hiding your body from yourself does not hide it from
|
||||||
|
/// anyone else. The Animator/NetworkAnimator are left untouched, so the third-person animation
|
||||||
|
/// keeps replicating to other players even while the mesh is culled from the owner's own view.
|
||||||
|
/// </summary>
|
||||||
|
[DisallowMultipleComponent]
|
||||||
|
public class PlayerBodyVisibility : NetworkBehaviour
|
||||||
|
{
|
||||||
|
#region Serialized Fields
|
||||||
|
|
||||||
|
[Header("Body")]
|
||||||
|
[Tooltip("Root of the third-person body mesh hierarchy (skinned mesh + bones).")]
|
||||||
|
[SerializeField] private GameObject bodyRoot;
|
||||||
|
|
||||||
|
[Header("Local hiding")]
|
||||||
|
[Tooltip("Layer the owner's own body is moved to. The first-person camera must exclude this layer from its culling mask.")]
|
||||||
|
[SerializeField] private string localBodyLayer = "LocalBody";
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Network Lifecycle
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Once ownership is resolved, relayers the third-person body so the owning player's own
|
||||||
|
/// camera stops rendering it. Remote copies return early and keep the body on its authored
|
||||||
|
/// (visible) layer, which is why other players still see it.
|
||||||
|
/// </summary>
|
||||||
|
public override void OnStartClient()
|
||||||
|
{
|
||||||
|
base.OnStartClient();
|
||||||
|
|
||||||
|
if (!base.IsOwner) return;
|
||||||
|
|
||||||
|
if (bodyRoot == null)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[PlayerBodyVisibility] '{name}' has no bodyRoot assigned — cannot hide the local body.", this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int layer = LayerMask.NameToLayer(localBodyLayer);
|
||||||
|
if (layer < 0)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[PlayerBodyVisibility] '{name}' — layer '{localBodyLayer}' does not exist. Add it in Project Settings ▸ Tags and Layers, then exclude it from the first-person camera's culling mask.", this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetLayerRecursively(bodyRoot, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Internal Helpers
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a layer to a GameObject and every descendant, since a skinned body spans many
|
||||||
|
/// child renderers and bones that must all move together for the camera cull to hide it fully.
|
||||||
|
/// </summary>
|
||||||
|
private static void SetLayerRecursively(GameObject root, int layer)
|
||||||
|
{
|
||||||
|
root.layer = layer;
|
||||||
|
foreach (Transform child in root.transform)
|
||||||
|
SetLayerRecursively(child.gameObject, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 23848f2e7f7ecba4791aa89d5344b3d0
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Ashwild.Network
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A tiny static hub for "another player joined / left my session" notifications, kept separate
|
||||||
|
/// from PlayerEvents on purpose: PlayerEvents represents the LOCAL owned player and §3 forbids a
|
||||||
|
/// remote player copy from driving it. These events are raised by SessionPresenceNotifier on the
|
||||||
|
/// networked player copies (a local-client view of the session roster) and consumed by the game
|
||||||
|
/// scene's presence feed UI. Names are already resolved display names, ready to show.
|
||||||
|
/// </summary>
|
||||||
|
public static class SessionPresenceEvents
|
||||||
|
{
|
||||||
|
#region Events
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A remote player just joined the session — carries their display name.
|
||||||
|
/// </summary>
|
||||||
|
public static event Action<string> PlayerJoined;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A remote player just left the session — carries their display name.
|
||||||
|
/// </summary>
|
||||||
|
public static event Action<string> PlayerLeft;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Raisers
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Announces that a remote player joined, to whoever is listening on this client.
|
||||||
|
/// </summary>
|
||||||
|
public static void RaisePlayerJoined(string playerName) => PlayerJoined?.Invoke(playerName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Announces that a remote player left, to whoever is listening on this client.
|
||||||
|
/// </summary>
|
||||||
|
public static void RaisePlayerLeft(string playerName) => PlayerLeft?.Invoke(playerName);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: db47bb9b3f6b5c747ae09b007c8aebf0
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using FishNet.Object;
|
||||||
|
using FishNet.Object.Synchronizing;
|
||||||
|
using Steamworks;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Ashwild.Network
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Announces this player's arrival and departure to every other client's presence feed. Lives on
|
||||||
|
/// the networked player prefab (like PlayerNameTag) and is NOT owner-gated — remote copies must run
|
||||||
|
/// it so they can report their own despawn as a "left".
|
||||||
|
///
|
||||||
|
/// Join is server-driven: the owner submits its Steam name, the server stores it and fires a
|
||||||
|
/// one-shot ObserversRpc to whoever is watching right now. Because the RPC isn't buffered, a client
|
||||||
|
/// joining a busy room never replays the joins that happened before it arrived — no startup burst.
|
||||||
|
/// Leave is client-detected: when a player's copy despawns (disconnect), each remaining client reads
|
||||||
|
/// the replicated name and reports it. The feed UI itself suppresses the departure storm that fires
|
||||||
|
/// when the local player is the one leaving the session.
|
||||||
|
/// </summary>
|
||||||
|
[DisallowMultipleComponent]
|
||||||
|
public class SessionPresenceNotifier : NetworkBehaviour
|
||||||
|
{
|
||||||
|
#region Networked State
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The owner's display name, written by the server so every client can label this player's
|
||||||
|
/// "left" message once the copy despawns and the owner is already gone.
|
||||||
|
/// </summary>
|
||||||
|
private readonly SyncVar<string> displayName = new SyncVar<string>();
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Network Lifecycle
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// On the owning client, publishes the local Steam persona name so the server can announce the join.
|
||||||
|
/// </summary>
|
||||||
|
public override void OnStartClient()
|
||||||
|
{
|
||||||
|
base.OnStartClient();
|
||||||
|
|
||||||
|
if (!base.IsOwner) return;
|
||||||
|
|
||||||
|
if (!SteamManager.Initialized)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[SessionPresence] '{name}' — Steam not initialized, cannot announce join.", this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SubmitPresenceServerRpc(SteamFriends.GetPersonaName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A player's copy is despawning on this client. On non-owner copies that means a remote player
|
||||||
|
/// left the session, so report it; the owner never reports its own departure. Gated on the local
|
||||||
|
/// client still running so that OUR own disconnect — which despawns every copy at once — doesn't
|
||||||
|
/// announce the whole room leaving.
|
||||||
|
/// </summary>
|
||||||
|
public override void OnStopClient()
|
||||||
|
{
|
||||||
|
base.OnStopClient();
|
||||||
|
|
||||||
|
if (base.IsOwner || !base.IsClientStarted) return;
|
||||||
|
|
||||||
|
string playerName = displayName.Value;
|
||||||
|
if (!string.IsNullOrEmpty(playerName))
|
||||||
|
SessionPresenceEvents.RaisePlayerLeft(playerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Replication
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side: records the owner's name and announces the join once to the current observers.
|
||||||
|
/// </summary>
|
||||||
|
[ServerRpc]
|
||||||
|
private void SubmitPresenceServerRpc(string persona)
|
||||||
|
{
|
||||||
|
displayName.Value = persona;
|
||||||
|
AnnounceJoinObserversRpc(persona);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires on every client observing this player at join time; each reports it to its feed,
|
||||||
|
/// except the owner who shouldn't be told they joined their own session.
|
||||||
|
/// </summary>
|
||||||
|
[ObserversRpc]
|
||||||
|
private void AnnounceJoinObserversRpc(string persona)
|
||||||
|
{
|
||||||
|
if (base.IsOwner) return;
|
||||||
|
SessionPresenceEvents.RaisePlayerJoined(persona);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8809701d89694f5488cc03b300f8dc09
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using FishNet;
|
||||||
|
using FishNet.Managing;
|
||||||
using Steamworks;
|
using Steamworks;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Ashwild.Player;
|
using Ashwild.Player;
|
||||||
@@ -8,13 +10,18 @@ namespace Ashwild.Network
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The one place that bridges Steam friend invites into the game. It does three things:
|
/// The one place that bridges Steam friend invites into the game. It does three things:
|
||||||
/// • SEND — opens the Steam overlay friends list so the local player can invite a friend to
|
/// • SEND — while in a session, keeps a private "carrier" Steam lobby (one per local player, all
|
||||||
/// their current session (the in-game pause menu "Invite" button calls OpenInviteOverlay).
|
/// advertising the same host connect code) so the pause-menu "Invite" button can open Steam's
|
||||||
|
/// lobby invite overlay. Any session member — host or client — can invite their own friends,
|
||||||
|
/// and every invite still points at the single host's room (see NetworkSessionManager.MaxPlayers).
|
||||||
/// • PRESENCE — publishes the session's connect code as Steam Rich Presence while online, so
|
/// • PRESENCE — publishes the session's connect code as Steam Rich Presence while online, so
|
||||||
/// friends see "Join Game" and invites carry the code that JoinSession understands.
|
/// friends see "Join Game" in their friends list and that path carries the code JoinSession reads.
|
||||||
/// • RECEIVE — listens for Steam's GameRichPresenceJoinRequested callback (fired when a friend
|
/// • RECEIVE — routes Steam's join paths to match the player's intent:
|
||||||
/// accepts our invite or clicks "Join Game") and raises PlayerEvents.InviteReceived so the
|
/// – LobbyInvite_t (invitation just ARRIVED) → raises PlayerEvents.InviteReceived so the menu
|
||||||
/// menu can pop the invitation card. Also resolves friend avatars (async via Steam).
|
/// pops the invitation card; the player joins with one in-game click, no Alt-Tab to Steam.
|
||||||
|
/// – GameLobbyJoinRequested_t / GameRichPresenceJoinRequested_t (player explicitly clicked
|
||||||
|
/// "Join" inside Steam) → joins the session directly, skipping the card.
|
||||||
|
/// Also resolves friend avatars (async via Steam) for the invitation card.
|
||||||
/// Place this on the persistent FishNet NetworkManager GameObject, beside NetworkSessionManager.
|
/// Place this on the persistent FishNet NetworkManager GameObject, beside NetworkSessionManager.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[DisallowMultipleComponent]
|
[DisallowMultipleComponent]
|
||||||
@@ -23,10 +30,16 @@ namespace Ashwild.Network
|
|||||||
#region Constants
|
#region Constants
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Steam Rich Presence key that makes the "Join Game" entry appear for friends.
|
/// Steam Rich Presence / lobby-data key that makes the "Join Game" entry appear for friends
|
||||||
|
/// and carries the connect code an incoming friend uses to reach our session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const string ConnectKey = "connect";
|
private const string ConnectKey = "connect";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fallback member cap for the carrier lobby when the session manager is unavailable.
|
||||||
|
/// </summary>
|
||||||
|
private const int FallbackMemberLimit = 6;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region State
|
#region State
|
||||||
@@ -37,15 +50,41 @@ namespace Ashwild.Network
|
|||||||
public static SteamInviteService Instance { get; private set; }
|
public static SteamInviteService Instance { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Steam callback fired when a friend accepts our invite or clicks "Join Game".
|
/// Steam callback fired when a friend clicks "Join Game" via our Rich Presence connect string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Callback<GameRichPresenceJoinRequested_t> joinRequestedCallback;
|
private Callback<GameRichPresenceJoinRequested_t> joinRequestedCallback;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Steam callback fired when the local player clicks "Join" on a lobby invite inside Steam.
|
||||||
|
/// </summary>
|
||||||
|
private Callback<GameLobbyJoinRequested_t> lobbyJoinRequestedCallback;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Steam callback fired the moment a friend's lobby invitation ARRIVES (before any accept).
|
||||||
|
/// </summary>
|
||||||
|
private Callback<LobbyInvite_t> lobbyInviteCallback;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Steam callback fired when an avatar image finishes downloading from Steam's servers.
|
/// Steam callback fired when an avatar image finishes downloading from Steam's servers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Callback<AvatarImageLoaded_t> avatarLoadedCallback;
|
private Callback<AvatarImageLoaded_t> avatarLoadedCallback;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Async result of CreateLobby — writes the connect code into the new lobby once it exists.
|
||||||
|
/// </summary>
|
||||||
|
private CallResult<LobbyCreated_t> lobbyCreatedResult;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The local player's carrier lobby while in a session (Nil when none). Exists only so the
|
||||||
|
/// Steam overlay has a lobby to invite friends to; nobody actually stays in it.
|
||||||
|
/// </summary>
|
||||||
|
private CSteamID currentLobby;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connect code to stamp onto the carrier lobby once CreateLobby completes.
|
||||||
|
/// </summary>
|
||||||
|
private string pendingLobbyCode;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Avatar requests still waiting on Steam to finish downloading the image, by SteamID64.
|
/// Avatar requests still waiting on Steam to finish downloading the image, by SteamID64.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -86,7 +125,8 @@ namespace Ashwild.Network
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unsubscribes from the bus and disposes the Steam callbacks — mirrors OnEnable.
|
/// Unsubscribes from the bus, tears down the carrier lobby, and disposes the Steam callbacks —
|
||||||
|
/// mirrors OnEnable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void OnDisable()
|
private void OnDisable()
|
||||||
{
|
{
|
||||||
@@ -94,10 +134,18 @@ namespace Ashwild.Network
|
|||||||
PlayerEvents.SessionJoined -= HandleSessionJoined;
|
PlayerEvents.SessionJoined -= HandleSessionJoined;
|
||||||
PlayerEvents.SessionStopped -= HandleSessionStopped;
|
PlayerEvents.SessionStopped -= HandleSessionStopped;
|
||||||
|
|
||||||
|
LeaveCarrierLobby();
|
||||||
|
|
||||||
joinRequestedCallback?.Dispose();
|
joinRequestedCallback?.Dispose();
|
||||||
|
lobbyJoinRequestedCallback?.Dispose();
|
||||||
|
lobbyInviteCallback?.Dispose();
|
||||||
avatarLoadedCallback?.Dispose();
|
avatarLoadedCallback?.Dispose();
|
||||||
|
lobbyCreatedResult?.Dispose();
|
||||||
joinRequestedCallback = null;
|
joinRequestedCallback = null;
|
||||||
|
lobbyJoinRequestedCallback = null;
|
||||||
|
lobbyInviteCallback = null;
|
||||||
avatarLoadedCallback = null;
|
avatarLoadedCallback = null;
|
||||||
|
lobbyCreatedResult = null;
|
||||||
callbacksReady = false;
|
callbacksReady = false;
|
||||||
|
|
||||||
pendingAvatars.Clear();
|
pendingAvatars.Clear();
|
||||||
@@ -112,10 +160,11 @@ namespace Ashwild.Network
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clears the singleton reference.
|
/// Clears the singleton reference and leaves any carrier lobby still open.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
|
LeaveCarrierLobby();
|
||||||
if (Instance == this) Instance = null;
|
if (Instance == this) Instance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +173,10 @@ namespace Ashwild.Network
|
|||||||
#region Public API
|
#region Public API
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the Steam overlay friends list so the player can invite a friend to the current
|
/// Opens the Steam overlay so the player can invite a friend to the current session. Prefers
|
||||||
/// session. Requires being in a session (host or client) so there is a code to share.
|
/// the lobby invite dialog (so the friend gets an in-game invitation card via LobbyInvite_t);
|
||||||
|
/// falls back to the plain connect-string dialog if the carrier lobby isn't ready. Requires
|
||||||
|
/// being in a session (host or client) so there is a code to share.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void OpenInviteOverlay()
|
public void OpenInviteOverlay()
|
||||||
{
|
{
|
||||||
@@ -142,7 +193,10 @@ namespace Ashwild.Network
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SteamFriends.ActivateGameOverlayInviteDialogConnectString(code);
|
if (currentLobby.IsValid())
|
||||||
|
SteamFriends.ActivateGameOverlayInviteDialog(currentLobby);
|
||||||
|
else
|
||||||
|
SteamFriends.ActivateGameOverlayInviteDialogConnectString(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -181,7 +235,44 @@ namespace Ashwild.Network
|
|||||||
#region Steam Callbacks
|
#region Steam Callbacks
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A friend accepted our invite / clicked "Join Game" — surface it to the menu popup.
|
/// A friend's lobby invitation just ARRIVED — surface it to the menu popup so the player can
|
||||||
|
/// join with one in-game click. The connect code is read from the inviter's Rich Presence,
|
||||||
|
/// which every session member publishes; an empty read means we can't build a joinable card.
|
||||||
|
/// </summary>
|
||||||
|
private void OnLobbyInvite(LobbyInvite_t cb)
|
||||||
|
{
|
||||||
|
CSteamID inviter = new CSteamID(cb.m_ulSteamIDUser);
|
||||||
|
string connect = ResolveConnectFromFriend(inviter);
|
||||||
|
if (string.IsNullOrEmpty(connect))
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[SteamInvite] Lobby invite from {cb.m_ulSteamIDUser} had no readable connect code — ignoring.", this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string inviterName = inviter.IsValid() ? SteamFriends.GetFriendPersonaName(inviter) : "Un ami";
|
||||||
|
Debug.Log($"[SteamInvite] Invitation reçue de {inviterName} (connect={connect}).", this);
|
||||||
|
PlayerEvents.RaiseInviteReceived(cb.m_ulSteamIDUser, inviterName, connect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The player clicked "Join" on a lobby invite inside Steam — an explicit intent, so we join
|
||||||
|
/// the session directly and skip the invitation card.
|
||||||
|
/// </summary>
|
||||||
|
private void OnLobbyJoinRequested(GameLobbyJoinRequested_t cb)
|
||||||
|
{
|
||||||
|
string connect = ResolveConnectFromFriend(cb.m_steamIDFriend);
|
||||||
|
if (string.IsNullOrEmpty(connect))
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[SteamInvite] Lobby join request had no readable connect code — ignoring.", this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JoinDirect(connect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A friend clicked "Join Game" via our Rich Presence connect string — an explicit intent, so
|
||||||
|
/// we join directly rather than popping the invitation card.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void OnJoinRequested(GameRichPresenceJoinRequested_t cb)
|
private void OnJoinRequested(GameRichPresenceJoinRequested_t cb)
|
||||||
{
|
{
|
||||||
@@ -192,13 +283,24 @@ namespace Ashwild.Network
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ulong inviterId = cb.m_steamIDFriend.m_SteamID;
|
JoinDirect(connect);
|
||||||
string inviterName = cb.m_steamIDFriend.IsValid()
|
}
|
||||||
? SteamFriends.GetFriendPersonaName(cb.m_steamIDFriend)
|
|
||||||
: "Un ami";
|
|
||||||
|
|
||||||
Debug.Log($"[SteamInvite] Invitation reçue de {inviterName} (connect={connect}).", this);
|
/// <summary>
|
||||||
PlayerEvents.RaiseInviteReceived(inviterId, inviterName, connect);
|
/// The carrier lobby finished creating — record it and stamp the connect code onto it so a
|
||||||
|
/// joining friend could also recover the code from lobby data if needed.
|
||||||
|
/// </summary>
|
||||||
|
private void OnLobbyCreated(LobbyCreated_t cb, bool ioFailure)
|
||||||
|
{
|
||||||
|
if (ioFailure || cb.m_eResult != EResult.k_EResultOK)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[SteamInvite] Failed to create the invite lobby (result={cb.m_eResult}, ioFailure={ioFailure}). Invites fall back to the connect string.", this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLobby = new CSteamID(cb.m_ulSteamIDLobby);
|
||||||
|
if (!string.IsNullOrEmpty(pendingLobbyCode))
|
||||||
|
SteamMatchmaking.SetLobbyData(currentLobby, ConnectKey, pendingLobbyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -218,22 +320,34 @@ namespace Ashwild.Network
|
|||||||
#region Event Handlers
|
#region Event Handlers
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Host came online — advertise the session code so friends can join/invite.
|
/// Host came online — advertise the session code and open a carrier lobby friends can be invited to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleSessionOnline(string code) => PublishConnect(code);
|
private void HandleSessionOnline(string code)
|
||||||
|
{
|
||||||
|
PublishConnect(code);
|
||||||
|
CreateCarrierLobby(code);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Joined a host — advertise the same code so this client can also invite friends.
|
/// Joined a host — advertise the same code and open a carrier lobby so this client can also
|
||||||
|
/// invite its own friends into the host's room.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleSessionJoined() => PublishConnect(PlayerEvents.SessionCode);
|
private void HandleSessionJoined()
|
||||||
|
{
|
||||||
|
string code = PlayerEvents.SessionCode;
|
||||||
|
PublishConnect(code);
|
||||||
|
CreateCarrierLobby(code);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Session ended — remove the Rich Presence so we stop advertising as joinable.
|
/// Session ended — stop advertising as joinable and tear down the carrier lobby.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleSessionStopped()
|
private void HandleSessionStopped()
|
||||||
{
|
{
|
||||||
if (SteamManager.Initialized)
|
if (SteamManager.Initialized)
|
||||||
SteamFriends.SetRichPresence(ConnectKey, null);
|
SteamFriends.SetRichPresence(ConnectKey, null);
|
||||||
|
|
||||||
|
LeaveCarrierLobby();
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -248,7 +362,10 @@ namespace Ashwild.Network
|
|||||||
if (callbacksReady || !SteamManager.Initialized) return;
|
if (callbacksReady || !SteamManager.Initialized) return;
|
||||||
|
|
||||||
joinRequestedCallback = Callback<GameRichPresenceJoinRequested_t>.Create(OnJoinRequested);
|
joinRequestedCallback = Callback<GameRichPresenceJoinRequested_t>.Create(OnJoinRequested);
|
||||||
|
lobbyJoinRequestedCallback = Callback<GameLobbyJoinRequested_t>.Create(OnLobbyJoinRequested);
|
||||||
|
lobbyInviteCallback = Callback<LobbyInvite_t>.Create(OnLobbyInvite);
|
||||||
avatarLoadedCallback = Callback<AvatarImageLoaded_t>.Create(OnAvatarLoaded);
|
avatarLoadedCallback = Callback<AvatarImageLoaded_t>.Create(OnAvatarLoaded);
|
||||||
|
lobbyCreatedResult = CallResult<LobbyCreated_t>.Create(OnLobbyCreated);
|
||||||
callbacksReady = true;
|
callbacksReady = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +378,88 @@ namespace Ashwild.Network
|
|||||||
SteamFriends.SetRichPresence(ConnectKey, code);
|
SteamFriends.SetRichPresence(ConnectKey, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens (or refreshes) the private carrier lobby that the invite overlay hangs its friend
|
||||||
|
/// invites off. Friends-only and capped at the room's player limit; the connect code is stamped
|
||||||
|
/// on once LobbyCreated returns. No-op without Steam or a code; refreshes data if one already exists.
|
||||||
|
/// </summary>
|
||||||
|
private void CreateCarrierLobby(string code)
|
||||||
|
{
|
||||||
|
if (!SteamManager.Initialized || string.IsNullOrEmpty(code)) return;
|
||||||
|
|
||||||
|
pendingLobbyCode = code;
|
||||||
|
|
||||||
|
if (currentLobby.IsValid())
|
||||||
|
{
|
||||||
|
SteamMatchmaking.SetLobbyData(currentLobby, ConnectKey, code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int memberLimit = NetworkSessionManager.Instance != null
|
||||||
|
? NetworkSessionManager.Instance.MaxPlayers
|
||||||
|
: FallbackMemberLimit;
|
||||||
|
|
||||||
|
SteamAPICall_t call = SteamMatchmaking.CreateLobby(ELobbyType.k_ELobbyTypeFriendsOnly, memberLimit);
|
||||||
|
lobbyCreatedResult?.Set(call);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Leaves and forgets the carrier lobby, if one is open.
|
||||||
|
/// </summary>
|
||||||
|
private void LeaveCarrierLobby()
|
||||||
|
{
|
||||||
|
pendingLobbyCode = null;
|
||||||
|
if (!currentLobby.IsValid()) return;
|
||||||
|
|
||||||
|
if (SteamManager.Initialized)
|
||||||
|
SteamMatchmaking.LeaveLobby(currentLobby);
|
||||||
|
currentLobby = CSteamID.Nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the connect code a session member publishes via Rich Presence. Returns empty when the
|
||||||
|
/// friend is invalid, offline, or hasn't published one (they may not be in a session).
|
||||||
|
/// </summary>
|
||||||
|
private string ResolveConnectFromFriend(CSteamID friend)
|
||||||
|
{
|
||||||
|
if (!SteamManager.Initialized || !friend.IsValid()) return string.Empty;
|
||||||
|
return SteamFriends.GetFriendRichPresence(friend, ConnectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connects straight to a session by its code — used when the player expressed an explicit
|
||||||
|
/// "Join" intent inside Steam. Ignored (with a log) when already in a session so a stray Steam
|
||||||
|
/// click can't tear down a running game.
|
||||||
|
/// </summary>
|
||||||
|
private void JoinDirect(string code)
|
||||||
|
{
|
||||||
|
if (IsAlreadyInSession())
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[SteamInvite] Ignoring a Steam join request — already in a session.", this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NetworkSessionManager.Instance == null)
|
||||||
|
{
|
||||||
|
Debug.LogError("[SteamInvite] Cannot join — NetworkSessionManager missing.", this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameSession.Mode = GameSession.LaunchMode.Join;
|
||||||
|
GameSession.JoinCode = code;
|
||||||
|
NetworkSessionManager.Instance.JoinSession(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when a client or server connection is already up, so we shouldn't start a fresh join.
|
||||||
|
/// </summary>
|
||||||
|
private bool IsAlreadyInSession()
|
||||||
|
{
|
||||||
|
NetworkManager nm = InstanceFinder.NetworkManager;
|
||||||
|
if (nm == null) return false;
|
||||||
|
return nm.ClientManager.Started || nm.ServerManager.Started;
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7bcd5cb7e1311614a8607e68424e119f
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Ashwild.Player
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Drives a humanoid Animator from the local player's PlayerEvents bus so a visible rig
|
||||||
|
/// reflects what the player is doing. It is a pure consumer of the bus — it never simulates
|
||||||
|
/// anything itself — which lets the exact same component drive two independent rigs from one
|
||||||
|
/// shared source of truth: the owner's first-person arms (seen only by the owner, never
|
||||||
|
/// networked) and his third-person body (replicated to other players by a sibling FishNet
|
||||||
|
/// NetworkAnimator). Because both rigs read the same events at the same frame, the arms the
|
||||||
|
/// owner sees and the body everyone else sees stay in lockstep by construction.
|
||||||
|
/// This is owner-only local simulation: add every instance to
|
||||||
|
/// PlayerNetworkController.ownerOnlyBehaviours so remote puppets never run it — a remote body
|
||||||
|
/// is animated by its replicated NetworkAnimator instead. Every parameter name is optional;
|
||||||
|
/// leave a field blank to skip that parameter, so a minimal arms rig can ignore the full
|
||||||
|
/// locomotion blend that the third-person body uses.
|
||||||
|
/// </summary>
|
||||||
|
[DisallowMultipleComponent]
|
||||||
|
public class PlayerAnimatorDriver : MonoBehaviour
|
||||||
|
{
|
||||||
|
#region Serialized Fields
|
||||||
|
|
||||||
|
[Header("Target")]
|
||||||
|
[SerializeField] private Animator animator;
|
||||||
|
[Tooltip("Transform whose facing defines local move direction. Defaults to the Animator's transform when left empty.")]
|
||||||
|
[SerializeField] private Transform orientation;
|
||||||
|
|
||||||
|
[Header("Float parameters (leave blank to skip)")]
|
||||||
|
[SerializeField] private string speedParam = "Speed";
|
||||||
|
[SerializeField] private string moveXParam = "MoveX";
|
||||||
|
[SerializeField] private string moveYParam = "MoveY";
|
||||||
|
[Tooltip("Planar speed (m/s) that maps to 1 in the blend tree. 0 feeds raw speed unscaled.")]
|
||||||
|
[SerializeField] private float normalizeSpeed = 6f;
|
||||||
|
[Tooltip("Damping applied to float parameters for smooth blends.")]
|
||||||
|
[SerializeField] private float floatDampTime = 0.1f;
|
||||||
|
|
||||||
|
[Header("Bool parameters (leave blank to skip)")]
|
||||||
|
[SerializeField] private string groundedParam = "Grounded";
|
||||||
|
[SerializeField] private string crouchingParam = "Crouching";
|
||||||
|
[SerializeField] private string sprintingParam = "Sprinting";
|
||||||
|
|
||||||
|
[Header("Trigger parameters (leave blank to skip)")]
|
||||||
|
[SerializeField] private string jumpParam = "Jump";
|
||||||
|
[SerializeField] private string attackParam = "Attack";
|
||||||
|
[SerializeField] private string landParam = "Land";
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region State
|
||||||
|
|
||||||
|
private Vector3 planarVelocity;
|
||||||
|
|
||||||
|
private int speedId, moveXId, moveYId;
|
||||||
|
private int groundedId, crouchingId, sprintingId;
|
||||||
|
private int jumpId, attackId, landId;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Unity Lifecycle
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Caches parameter hashes (0 for blank names, which are then skipped) and defaults the
|
||||||
|
/// orientation reference so local move direction works without extra wiring.
|
||||||
|
/// </summary>
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
speedId = Hash(speedParam);
|
||||||
|
moveXId = Hash(moveXParam);
|
||||||
|
moveYId = Hash(moveYParam);
|
||||||
|
groundedId = Hash(groundedParam);
|
||||||
|
crouchingId = Hash(crouchingParam);
|
||||||
|
sprintingId = Hash(sprintingParam);
|
||||||
|
jumpId = Hash(jumpParam);
|
||||||
|
attackId = Hash(attackParam);
|
||||||
|
landId = Hash(landParam);
|
||||||
|
|
||||||
|
if (orientation == null && animator != null)
|
||||||
|
orientation = animator.transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes to the locomotion/tool events that map onto animation, and seeds the bool
|
||||||
|
/// parameters from the bus's current state so a freshly enabled rig starts in the right pose.
|
||||||
|
/// </summary>
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
PlayerEvents.VelocityChanged += HandleVelocityChanged;
|
||||||
|
PlayerEvents.GroundedChanged += HandleGroundedChanged;
|
||||||
|
PlayerEvents.CrouchChanged += HandleCrouchChanged;
|
||||||
|
PlayerEvents.SprintChanged += HandleSprintChanged;
|
||||||
|
PlayerEvents.Jumped += HandleJumped;
|
||||||
|
PlayerEvents.AttackSwung += HandleAttackSwung;
|
||||||
|
PlayerEvents.LandingStunStarted += HandleLandingStunStarted;
|
||||||
|
|
||||||
|
SetBool(groundedId, PlayerEvents.IsGrounded);
|
||||||
|
SetBool(crouchingId, PlayerEvents.IsCrouching);
|
||||||
|
SetBool(sprintingId, PlayerEvents.IsSprinting);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unsubscribes — mirrors OnEnable exactly.
|
||||||
|
/// </summary>
|
||||||
|
private void OnDisable()
|
||||||
|
{
|
||||||
|
PlayerEvents.VelocityChanged -= HandleVelocityChanged;
|
||||||
|
PlayerEvents.GroundedChanged -= HandleGroundedChanged;
|
||||||
|
PlayerEvents.CrouchChanged -= HandleCrouchChanged;
|
||||||
|
PlayerEvents.SprintChanged -= HandleSprintChanged;
|
||||||
|
PlayerEvents.Jumped -= HandleJumped;
|
||||||
|
PlayerEvents.AttackSwung -= HandleAttackSwung;
|
||||||
|
PlayerEvents.LandingStunStarted -= HandleLandingStunStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes the smoothed movement floats every frame so blend trees ease between poses
|
||||||
|
/// instead of snapping when velocity changes.
|
||||||
|
/// </summary>
|
||||||
|
private void Update()
|
||||||
|
{
|
||||||
|
if (animator == null) return;
|
||||||
|
|
||||||
|
float scale = normalizeSpeed > 0f ? 1f / normalizeSpeed : 1f;
|
||||||
|
SetFloat(speedId, planarVelocity.magnitude * scale);
|
||||||
|
|
||||||
|
if (moveXId != 0 || moveYId != 0)
|
||||||
|
{
|
||||||
|
Vector3 local = orientation != null
|
||||||
|
? orientation.InverseTransformDirection(planarVelocity)
|
||||||
|
: planarVelocity;
|
||||||
|
SetFloat(moveXId, local.x * scale);
|
||||||
|
SetFloat(moveYId, local.z * scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Event Handlers
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Caches the planar (horizontal) velocity that Update turns into blend-tree floats.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleVelocityChanged(Vector3 velocity)
|
||||||
|
{
|
||||||
|
planarVelocity = new Vector3(velocity.x, 0f, velocity.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleGroundedChanged(bool grounded, float impactSpeed) => SetBool(groundedId, grounded);
|
||||||
|
private void HandleCrouchChanged(bool crouching) => SetBool(crouchingId, crouching);
|
||||||
|
private void HandleSprintChanged(bool sprinting) => SetBool(sprintingId, sprinting);
|
||||||
|
private void HandleJumped() => SetTrigger(jumpId);
|
||||||
|
private void HandleAttackSwung() => SetTrigger(attackId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A landing stun begins on a hard touchdown — the cue for a landing animation.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleLandingStunStarted(float duration) => SetTrigger(landId);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Internal Helpers
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hashes a parameter name, returning 0 for a blank name so the setters can skip it.
|
||||||
|
/// </summary>
|
||||||
|
private int Hash(string paramName) => string.IsNullOrEmpty(paramName) ? 0 : Animator.StringToHash(paramName);
|
||||||
|
|
||||||
|
private void SetFloat(int id, float value)
|
||||||
|
{
|
||||||
|
if (id != 0) animator.SetFloat(id, value, floatDampTime, Time.deltaTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetBool(int id, bool value)
|
||||||
|
{
|
||||||
|
if (id != 0 && animator != null) animator.SetBool(id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetTrigger(int id)
|
||||||
|
{
|
||||||
|
if (id != 0 && animator != null) animator.SetTrigger(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8c1abd96334ddec4f8abf1ccdde057f9
|
||||||
@@ -47,6 +47,7 @@ namespace Ashwild.Player
|
|||||||
public static event Action<bool> SprintHeld;
|
public static event Action<bool> SprintHeld;
|
||||||
public static event Action CrouchPressed;
|
public static event Action CrouchPressed;
|
||||||
public static event Action AttackPressed;
|
public static event Action AttackPressed;
|
||||||
|
public static event Action SecondaryUsePressed; // right-click / secondary use (build menu, aim, block, ...)
|
||||||
public static event Action InteractPressed;
|
public static event Action InteractPressed;
|
||||||
public static event Action DropPressed;
|
public static event Action DropPressed;
|
||||||
public static event Action InventoryTogglePressed;
|
public static event Action InventoryTogglePressed;
|
||||||
@@ -115,6 +116,12 @@ namespace Ashwild.Player
|
|||||||
public static event Action EquipAnimComplete;
|
public static event Action EquipAnimComplete;
|
||||||
public static event Action UnequipAnimComplete;
|
public static event Action UnequipAnimComplete;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Building events
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
public static event Action BuildMenuToggleRequested; // the build hammer asks to open/close the construction menu
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Cooking events
|
// Cooking events
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -166,6 +173,7 @@ namespace Ashwild.Player
|
|||||||
public static void RaiseSprintHeld(bool b) { Log(nameof(SprintHeld)); SprintHeld?.Invoke(b); }
|
public static void RaiseSprintHeld(bool b) { Log(nameof(SprintHeld)); SprintHeld?.Invoke(b); }
|
||||||
public static void RaiseCrouchPressed() { Log(nameof(CrouchPressed)); CrouchPressed?.Invoke(); }
|
public static void RaiseCrouchPressed() { Log(nameof(CrouchPressed)); CrouchPressed?.Invoke(); }
|
||||||
public static void RaiseAttackPressed() { Log(nameof(AttackPressed)); AttackPressed?.Invoke(); }
|
public static void RaiseAttackPressed() { Log(nameof(AttackPressed)); AttackPressed?.Invoke(); }
|
||||||
|
public static void RaiseSecondaryUsePressed() { Log(nameof(SecondaryUsePressed)); SecondaryUsePressed?.Invoke(); }
|
||||||
public static void RaiseInteractPressed() { Log(nameof(InteractPressed)); InteractPressed?.Invoke(); }
|
public static void RaiseInteractPressed() { Log(nameof(InteractPressed)); InteractPressed?.Invoke(); }
|
||||||
public static void RaiseDropPressed() { Log(nameof(DropPressed)); DropPressed?.Invoke(); }
|
public static void RaiseDropPressed() { Log(nameof(DropPressed)); DropPressed?.Invoke(); }
|
||||||
public static void RaiseInventoryTogglePressed() { Log(nameof(InventoryTogglePressed)); InventoryTogglePressed?.Invoke(); }
|
public static void RaiseInventoryTogglePressed() { Log(nameof(InventoryTogglePressed)); InventoryTogglePressed?.Invoke(); }
|
||||||
@@ -258,6 +266,8 @@ namespace Ashwild.Player
|
|||||||
public static void RaiseEquipAnimComplete() { EquipAnimComplete?.Invoke(); }
|
public static void RaiseEquipAnimComplete() { EquipAnimComplete?.Invoke(); }
|
||||||
public static void RaiseUnequipAnimComplete() { UnequipAnimComplete?.Invoke(); }
|
public static void RaiseUnequipAnimComplete() { UnequipAnimComplete?.Invoke(); }
|
||||||
|
|
||||||
|
public static void RaiseBuildMenuToggleRequested() { Log(nameof(BuildMenuToggleRequested)); BuildMenuToggleRequested?.Invoke(); }
|
||||||
|
|
||||||
public static void RaiseFoodPlacedToCook(ItemData raw) { Log(nameof(FoodPlacedToCook)); FoodPlacedToCook?.Invoke(raw); }
|
public static void RaiseFoodPlacedToCook(ItemData raw) { Log(nameof(FoodPlacedToCook)); FoodPlacedToCook?.Invoke(raw); }
|
||||||
public static void RaiseFoodCookedReady(ItemData cooked) { Log(nameof(FoodCookedReady)); FoodCookedReady?.Invoke(cooked); }
|
public static void RaiseFoodCookedReady(ItemData cooked) { Log(nameof(FoodCookedReady)); FoodCookedReady?.Invoke(cooked); }
|
||||||
public static void RaiseFuelAdded(ItemData fuel) { Log(nameof(FuelAdded)); FuelAdded?.Invoke(fuel); }
|
public static void RaiseFuelAdded(ItemData fuel) { Log(nameof(FuelAdded)); FuelAdded?.Invoke(fuel); }
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using Ashwild.Inventory;
|
||||||
|
|
||||||
|
namespace Ashwild.Player
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Held-item logic for the build hammer, placed on its hand prefab. While equipped it
|
||||||
|
/// listens to the secondary-use input (right-click) and asks the construction UI to
|
||||||
|
/// open/close through the bus — it never reaches for the menu itself, so the hammer
|
||||||
|
/// stays a pure input source and the UI owns its own state. Left-click placement will
|
||||||
|
/// be added on top of this once the build menu exists.
|
||||||
|
/// </summary>
|
||||||
|
[DisallowMultipleComponent]
|
||||||
|
public class BuildHammerBehaviour : MonoBehaviour, IHeldItemBehaviour
|
||||||
|
{
|
||||||
|
#region Serialized Fields
|
||||||
|
|
||||||
|
[Header("Cooldown")]
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum time, in seconds, between two menu toggles so a held right-click
|
||||||
|
/// does not flicker the menu open and shut.
|
||||||
|
/// </summary>
|
||||||
|
[SerializeField] private float toggleCooldown = 0.25f;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region State
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Earliest time, in seconds, the next menu toggle is allowed.
|
||||||
|
/// </summary>
|
||||||
|
private float nextToggleTime;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Unity Lifecycle
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes to the secondary-use input for as long as the hammer is held.
|
||||||
|
/// </summary>
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
PlayerEvents.SecondaryUsePressed += HandleSecondaryUse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unsubscribes when the hammer is put away (the prefab is destroyed).
|
||||||
|
/// </summary>
|
||||||
|
private void OnDisable()
|
||||||
|
{
|
||||||
|
PlayerEvents.SecondaryUsePressed -= HandleSecondaryUse;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region IHeldItemBehaviour
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nothing to link yet: toggling the menu needs no player refs. Present to satisfy
|
||||||
|
/// the held-item contract; placement logic added later will use the context.
|
||||||
|
/// </summary>
|
||||||
|
public void Setup(HeldItemContext context, ItemData item)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Event Handlers
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requests the construction menu to open/close on right-click, gated by lock and
|
||||||
|
/// cooldown. The UI decides whether that opens or closes it.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleSecondaryUse()
|
||||||
|
{
|
||||||
|
if (PlayerEvents.InputLocked) return;
|
||||||
|
if (Time.time < nextToggleTime) return;
|
||||||
|
|
||||||
|
nextToggleTime = Time.time + toggleCooldown;
|
||||||
|
PlayerEvents.RaiseBuildMenuToggleRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 59ffa82caa5b57841a87a6f1e5d8839d
|
||||||
@@ -199,6 +199,7 @@ namespace Ashwild.Player
|
|||||||
WireHold(playerMap, "Sprint", PlayerEvents.RaiseSprintHeld);
|
WireHold(playerMap, "Sprint", PlayerEvents.RaiseSprintHeld);
|
||||||
WireButton(playerMap, "Crouch", PlayerEvents.RaiseCrouchPressed);
|
WireButton(playerMap, "Crouch", PlayerEvents.RaiseCrouchPressed);
|
||||||
WireButton(playerMap, "Attack", PlayerEvents.RaiseAttackPressed);
|
WireButton(playerMap, "Attack", PlayerEvents.RaiseAttackPressed);
|
||||||
|
WireButton(playerMap, "SecondaryUse", PlayerEvents.RaiseSecondaryUsePressed);
|
||||||
WireButton(playerMap, "Interact", PlayerEvents.RaiseInteractPressed);
|
WireButton(playerMap, "Interact", PlayerEvents.RaiseInteractPressed);
|
||||||
WireButton(playerMap, "Drop", PlayerEvents.RaiseDropPressed);
|
WireButton(playerMap, "Drop", PlayerEvents.RaiseDropPressed);
|
||||||
WireButton(playerMap, "Inventory", PlayerEvents.RaiseInventoryTogglePressed);
|
WireButton(playerMap, "Inventory", PlayerEvents.RaiseInventoryTogglePressed);
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using Ashwild.Network;
|
||||||
|
using Ashwild.Player;
|
||||||
|
|
||||||
|
namespace Ashwild.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The in-game feed that spawns a "X a rejoint / a quitté la partie" line whenever a remote player
|
||||||
|
/// joins or leaves the session. Listens to the network layer's SessionPresenceEvents (raised on
|
||||||
|
/// every client, host or not) and instantiates a PresenceFeedEntry under a holder whose layout group
|
||||||
|
/// stacks the lines. Drop this on the holder (or any object referencing it) in the game scene.
|
||||||
|
///
|
||||||
|
/// While the local player is tearing the session down (returning to the menu, or the host stopping)
|
||||||
|
/// every remote copy despawns at once, which would spam "left" lines; this feed goes quiet on those
|
||||||
|
/// bus events so that storm never reaches the screen.
|
||||||
|
/// </summary>
|
||||||
|
[DisallowMultipleComponent]
|
||||||
|
public class PlayerPresenceFeedUI : MonoBehaviour
|
||||||
|
{
|
||||||
|
#region Serialized Fields
|
||||||
|
|
||||||
|
[Header("References")]
|
||||||
|
[Tooltip("Holder the entries are parented to — give it a VerticalLayoutGroup so lines stack.")]
|
||||||
|
[SerializeField] private RectTransform holder;
|
||||||
|
|
||||||
|
[Tooltip("Prefab with a PresenceFeedEntry (CanvasGroup + label) instantiated per notification.")]
|
||||||
|
[SerializeField] private PresenceFeedEntry entryPrefab;
|
||||||
|
|
||||||
|
[Header("Messages")]
|
||||||
|
[Tooltip("Join line format — {0} is the player name.")]
|
||||||
|
[SerializeField] private string joinFormat = "{0} a rejoint la partie";
|
||||||
|
|
||||||
|
[Tooltip("Leave line format — {0} is the player name.")]
|
||||||
|
[SerializeField] private string leaveFormat = "{0} a quitté la partie";
|
||||||
|
|
||||||
|
[Header("Colours")]
|
||||||
|
[SerializeField] private Color joinColor = new Color(0.45f, 1f, 0.45f);
|
||||||
|
[SerializeField] private Color leaveColor = new Color(1f, 0.55f, 0.45f);
|
||||||
|
|
||||||
|
[Header("Timing")]
|
||||||
|
[Tooltip("Seconds a line stays fully visible before fading out.")]
|
||||||
|
[SerializeField] private float holdDuration = 4f;
|
||||||
|
[SerializeField] private float fadeInDuration = 0.3f;
|
||||||
|
[SerializeField] private float fadeOutDuration = 0.5f;
|
||||||
|
|
||||||
|
[Header("Limits")]
|
||||||
|
[Tooltip("Oldest lines are removed once this many are on screen at once.")]
|
||||||
|
[SerializeField] private int maxEntries = 5;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region State
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Currently displayed lines, oldest first — trimmed from the front when over the cap.
|
||||||
|
/// </summary>
|
||||||
|
private readonly List<PresenceFeedEntry> activeEntries = new List<PresenceFeedEntry>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True once the session is tearing down, so the despawn-driven "left" storm is ignored.
|
||||||
|
/// </summary>
|
||||||
|
private bool suppressed;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Unity Lifecycle
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes to presence notifications and the teardown signals that mute the feed.
|
||||||
|
/// </summary>
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
SessionPresenceEvents.PlayerJoined += HandlePlayerJoined;
|
||||||
|
SessionPresenceEvents.PlayerLeft += HandlePlayerLeft;
|
||||||
|
PlayerEvents.ReturningToMenu += HandleTeardown;
|
||||||
|
PlayerEvents.SessionStopped += HandleTeardown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unsubscribes — mirrors OnEnable exactly.
|
||||||
|
/// </summary>
|
||||||
|
private void OnDisable()
|
||||||
|
{
|
||||||
|
SessionPresenceEvents.PlayerJoined -= HandlePlayerJoined;
|
||||||
|
SessionPresenceEvents.PlayerLeft -= HandlePlayerLeft;
|
||||||
|
PlayerEvents.ReturningToMenu -= HandleTeardown;
|
||||||
|
PlayerEvents.SessionStopped -= HandleTeardown;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Event Handlers
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows a green arrival line for the named player.
|
||||||
|
/// </summary>
|
||||||
|
private void HandlePlayerJoined(string playerName) => Push(string.Format(joinFormat, playerName), joinColor);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows an orange departure line for the named player.
|
||||||
|
/// </summary>
|
||||||
|
private void HandlePlayerLeft(string playerName) => Push(string.Format(leaveFormat, playerName), leaveColor);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mutes the feed for the rest of its lifetime once the session is being torn down.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleTeardown() => suppressed = true;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Internal Helpers
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instantiates a feed line, trimming the oldest ones past the cap. Ignored while muted or when
|
||||||
|
/// a reference is missing (logged once so a mis-wired holder/prefab is obvious).
|
||||||
|
/// </summary>
|
||||||
|
private void Push(string message, Color color)
|
||||||
|
{
|
||||||
|
if (suppressed) return;
|
||||||
|
|
||||||
|
if (holder == null || entryPrefab == null)
|
||||||
|
{
|
||||||
|
Debug.LogError("[PresenceFeed] Holder or entry prefab not assigned — cannot show notifications.", this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeEntries.RemoveAll(e => e == null);
|
||||||
|
|
||||||
|
while (activeEntries.Count >= maxEntries)
|
||||||
|
{
|
||||||
|
PresenceFeedEntry oldest = activeEntries[0];
|
||||||
|
activeEntries.RemoveAt(0);
|
||||||
|
if (oldest != null) Destroy(oldest.gameObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
PresenceFeedEntry entry = Instantiate(entryPrefab, holder);
|
||||||
|
entry.Play(message, color, holdDuration, fadeInDuration, fadeOutDuration);
|
||||||
|
activeEntries.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e34a77305857ab84a87431aca9903af0
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using TMPro;
|
||||||
|
using DG.Tweening;
|
||||||
|
|
||||||
|
namespace Ashwild.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A single line in the player presence feed (e.g. "Alex a rejoint la partie"). Fades itself in,
|
||||||
|
/// holds, fades out, then self-destructs — the parent holder's layout group handles stacking, so
|
||||||
|
/// this only ever animates its own alpha and never fights the layout for position.
|
||||||
|
/// </summary>
|
||||||
|
[DisallowMultipleComponent]
|
||||||
|
[RequireComponent(typeof(CanvasGroup))]
|
||||||
|
public class PresenceFeedEntry : MonoBehaviour
|
||||||
|
{
|
||||||
|
#region Serialized Fields
|
||||||
|
|
||||||
|
[Header("References")]
|
||||||
|
[Tooltip("The label that shows the join/leave message.")]
|
||||||
|
[SerializeField] private TMP_Text label;
|
||||||
|
|
||||||
|
[Tooltip("Faded in and out over the entry's lifetime. Auto-fetched from this object if left empty.")]
|
||||||
|
[SerializeField] private CanvasGroup canvasGroup;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region State
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The full lifecycle tween (fade in → hold → fade out → destroy), killed on teardown.
|
||||||
|
/// </summary>
|
||||||
|
private Sequence lifecycle;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Unity Lifecycle
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Caches the CanvasGroup when it wasn't wired in the inspector.
|
||||||
|
/// </summary>
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
if (canvasGroup == null) canvasGroup = GetComponent<CanvasGroup>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kills the lifecycle tween so it never targets a destroyed object.
|
||||||
|
/// </summary>
|
||||||
|
private void OnDestroy()
|
||||||
|
{
|
||||||
|
lifecycle?.Kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Public API
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fills in the message and colour, then runs the fade-in → hold → fade-out → destroy sequence.
|
||||||
|
/// Safe to call once right after instantiation.
|
||||||
|
/// </summary>
|
||||||
|
public void Play(string message, Color color, float holdDuration, float fadeInDuration, float fadeOutDuration)
|
||||||
|
{
|
||||||
|
if (label != null)
|
||||||
|
{
|
||||||
|
label.text = message;
|
||||||
|
label.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasGroup.alpha = 0f;
|
||||||
|
|
||||||
|
lifecycle = DOTween.Sequence();
|
||||||
|
lifecycle.Append(canvasGroup.DOFade(1f, fadeInDuration).SetEase(Ease.OutQuad));
|
||||||
|
lifecycle.AppendInterval(holdDuration);
|
||||||
|
lifecycle.Append(canvasGroup.DOFade(0f, fadeOutDuration).SetEase(Ease.InQuad));
|
||||||
|
lifecycle.OnComplete(() => Destroy(gameObject));
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4c87382f506a62e489d4ea7a432accac
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"com.unity.ide.visualstudio": "2.0.26",
|
"com.unity.ide.visualstudio": "2.0.26",
|
||||||
"com.unity.inputsystem": "1.19.0",
|
"com.unity.inputsystem": "1.19.0",
|
||||||
"com.unity.multiplayer.center": "1.0.1",
|
"com.unity.multiplayer.center": "1.0.1",
|
||||||
|
"com.unity.recorder": "5.1.6",
|
||||||
"com.unity.render-pipelines.universal": "17.3.0",
|
"com.unity.render-pipelines.universal": "17.3.0",
|
||||||
"com.unity.test-framework": "1.6.0",
|
"com.unity.test-framework": "1.6.0",
|
||||||
"com.unity.timeline": "1.8.12",
|
"com.unity.timeline": "1.8.12",
|
||||||
|
|||||||
@@ -15,6 +15,15 @@
|
|||||||
},
|
},
|
||||||
"url": "https://packages.unity.com"
|
"url": "https://packages.unity.com"
|
||||||
},
|
},
|
||||||
|
"com.unity.bindings.openimageio": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"depth": 1,
|
||||||
|
"source": "registry",
|
||||||
|
"dependencies": {
|
||||||
|
"com.unity.collections": "1.2.4"
|
||||||
|
},
|
||||||
|
"url": "https://packages.unity.com"
|
||||||
|
},
|
||||||
"com.unity.burst": {
|
"com.unity.burst": {
|
||||||
"version": "1.8.29",
|
"version": "1.8.29",
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -34,7 +43,7 @@
|
|||||||
},
|
},
|
||||||
"com.unity.collections": {
|
"com.unity.collections": {
|
||||||
"version": "2.6.6",
|
"version": "2.6.6",
|
||||||
"depth": 2,
|
"depth": 1,
|
||||||
"source": "registry",
|
"source": "registry",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"com.unity.burst": "1.8.28",
|
"com.unity.burst": "1.8.28",
|
||||||
@@ -102,11 +111,22 @@
|
|||||||
},
|
},
|
||||||
"com.unity.nuget.mono-cecil": {
|
"com.unity.nuget.mono-cecil": {
|
||||||
"version": "1.11.6",
|
"version": "1.11.6",
|
||||||
"depth": 3,
|
"depth": 2,
|
||||||
"source": "registry",
|
"source": "registry",
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"url": "https://packages.unity.com"
|
"url": "https://packages.unity.com"
|
||||||
},
|
},
|
||||||
|
"com.unity.recorder": {
|
||||||
|
"version": "5.1.6",
|
||||||
|
"depth": 0,
|
||||||
|
"source": "registry",
|
||||||
|
"dependencies": {
|
||||||
|
"com.unity.timeline": "1.8.7",
|
||||||
|
"com.unity.collections": "1.2.4",
|
||||||
|
"com.unity.bindings.openimageio": "1.0.2"
|
||||||
|
},
|
||||||
|
"url": "https://packages.unity.com"
|
||||||
|
},
|
||||||
"com.unity.render-pipelines.core": {
|
"com.unity.render-pipelines.core": {
|
||||||
"version": "17.3.0",
|
"version": "17.3.0",
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
@@ -167,7 +187,7 @@
|
|||||||
},
|
},
|
||||||
"com.unity.test-framework.performance": {
|
"com.unity.test-framework.performance": {
|
||||||
"version": "3.5.0",
|
"version": "3.5.0",
|
||||||
"depth": 3,
|
"depth": 2,
|
||||||
"source": "registry",
|
"source": "registry",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"com.unity.test-framework": "1.1.33",
|
"com.unity.test-framework": "1.1.33",
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
--- !u!78 &1
|
--- !u!78 &1
|
||||||
TagManager:
|
TagManager:
|
||||||
serializedVersion: 3
|
serializedVersion: 3
|
||||||
tags: []
|
tags:
|
||||||
|
- OreNode
|
||||||
|
- OrePickup
|
||||||
layers:
|
layers:
|
||||||
- Default
|
- Default
|
||||||
- TransparentFX
|
- TransparentFX
|
||||||
@@ -17,7 +19,7 @@ TagManager:
|
|||||||
- Build
|
- Build
|
||||||
- Player
|
- Player
|
||||||
- Pickable
|
- Pickable
|
||||||
-
|
- LocalBody
|
||||||
-
|
-
|
||||||
-
|
-
|
||||||
-
|
-
|
||||||
|
|||||||
Reference in New Issue
Block a user