diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b70a305e..578edceb 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -25,7 +25,11 @@ Context for current work: 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 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. - 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()`, which also matches disabled remote 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`) - Make it a `NetworkBehaviour`. **No destructive `Awake` singleton guard** (it would destroy diff --git a/Assets/GAME/InputSystem_Actions.inputactions b/Assets/GAME/InputSystem_Actions.inputactions index 16c6791e..606f9041 100644 --- a/Assets/GAME/InputSystem_Actions.inputactions +++ b/Assets/GAME/InputSystem_Actions.inputactions @@ -185,6 +185,15 @@ "processors": "", "interactions": "", "initialStateCheck": false + }, + { + "name": "SecondaryUse", + "type": "Button", + "id": "c1d2e3f4-0001-4001-8001-000000000001", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false } ], "bindings": [ @@ -419,6 +428,28 @@ "isComposite": false, "isPartOfComposite": false }, + { + "name": "", + "id": "c1d2e3f4-0002-4002-8002-000000000002", + "path": "/rightButton", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "SecondaryUse", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "c1d2e3f4-0003-4003-8003-000000000003", + "path": "/leftTrigger", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "SecondaryUse", + "isComposite": false, + "isPartOfComposite": false + }, { "name": "", "id": "b1b01001-0001-0001-0001-000000000001", diff --git a/Assets/GAME/Prefabs/Player/Player.prefab b/Assets/GAME/Prefabs/Player/Player.prefab index 5c79f7d5..67b22185 100644 --- a/Assets/GAME/Prefabs/Player/Player.prefab +++ b/Assets/GAME/Prefabs/Player/Player.prefab @@ -430,6 +430,8 @@ GameObject: - component: {fileID: 4782095384616423459} - component: {fileID: 1509526206074017255} - component: {fileID: 1890885114087150018} + - component: {fileID: 656527942023048284} + - component: {fileID: 4969709554801488359} m_Layer: 10 m_Name: Player m_TagString: Untagged @@ -904,8 +906,6 @@ MonoBehaviour: _addedNetworkObject: {fileID: -5889822588553870904} _networkObjectCache: {fileID: -5889822588553870904} ownerOnlyBehaviours: - - {fileID: 0} - - {fileID: 0} - {fileID: 9009865201504802574} - {fileID: 6967422961657961542} - {fileID: 8358435118901160354} @@ -1035,7 +1035,39 @@ MonoBehaviour: nameCanvas: {fileID: 5566668569623061449} nameLabel: {fileID: 7210073718532933207} 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 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/GAME/Prefabs/Tools/hammer01.prefab b/Assets/GAME/Prefabs/Tools/hammer01.prefab index b4f248aa..7214c93b 100644 --- a/Assets/GAME/Prefabs/Tools/hammer01.prefab +++ b/Assets/GAME/Prefabs/Tools/hammer01.prefab @@ -11,7 +11,7 @@ GameObject: - component: {fileID: 4529112889726762} - component: {fileID: 33852252603615026} - component: {fileID: 23994028819166706} - - component: {fileID: 7418467708994617123} + - component: {fileID: 3553698819402378907} m_Layer: 8 m_Name: hammer01 m_TagString: Untagged @@ -91,7 +91,7 @@ MeshRenderer: m_SortingOrder: 0 m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} ---- !u!114 &7418467708994617123 +--- !u!114 &3553698819402378907 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -100,13 +100,7 @@ MonoBehaviour: m_GameObject: {fileID: 1085285520790580} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: ccc8774893954fc40a153ddb5fdb1ffb, type: 3} + m_Script: {fileID: 11500000, guid: 59ffa82caa5b57841a87a6f1e5d8839d, type: 3} m_Name: - m_EditorClassIdentifier: Assembly-CSharp::ToolBehaviour - harvestRange: 3 - harvestMask: - serializedVersion: 2 - m_Bits: 4294967295 - baseDamage: 10 - wrongToolMultiplier: 0.25 - swingCooldown: 0.5 + m_EditorClassIdentifier: Assembly-CSharp::Ashwild.Player.BuildHammerBehaviour + toggleCooldown: 0.25 diff --git a/Assets/GAME/Prefabs/UI/Player_Canvas.prefab b/Assets/GAME/Prefabs/UI/Player_Canvas.prefab index 03b7e346..a28e6b3d 100644 --- a/Assets/GAME/Prefabs/UI/Player_Canvas.prefab +++ b/Assets/GAME/Prefabs/UI/Player_Canvas.prefab @@ -3599,6 +3599,83 @@ MonoBehaviour: m_FillOrigin: 0 m_UseSpriteMesh: 0 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 GameObject: m_ObjectHideFlags: 0 @@ -4673,6 +4750,7 @@ RectTransform: - {fileID: 5307559709507874296} - {fileID: 1750987845964819782} - {fileID: 7001037734087836054} + - {fileID: 7525940299773702526} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} @@ -6521,7 +6599,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3} propertyPath: m_LocalPosition.z - value: 0.85136575 + value: 0 objectReference: {fileID: 0} - target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3} propertyPath: m_LocalRotation.w @@ -6541,11 +6619,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3} propertyPath: m_AnchoredPosition.x - value: 271.68515 + value: 0 objectReference: {fileID: 0} - target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3} propertyPath: m_AnchoredPosition.y - value: -401.69788 + value: 0 objectReference: {fileID: 0} - target: {fileID: 984103149871578551, guid: 045c84a446203064ebf20403651f34f5, type: 3} propertyPath: m_LocalEulerAnglesHint.x diff --git a/Assets/GAME/Prefabs/UI/Slot Ui.prefab b/Assets/GAME/Prefabs/UI/Slot Ui.prefab index e16f0f7e..1bd27663 100644 --- a/Assets/GAME/Prefabs/UI/Slot Ui.prefab +++ b/Assets/GAME/Prefabs/UI/Slot Ui.prefab @@ -409,7 +409,7 @@ MonoBehaviour: highlightImage: {fileID: 9078470808336705615} usageBarRoot: {fileID: 5983078061427925415} 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} --- !u!1 &8681194172915985056 GameObject: diff --git a/Assets/GAME/Prefabs/UI/Text.meta b/Assets/GAME/Prefabs/UI/Text.meta new file mode 100644 index 00000000..f9384bd9 --- /dev/null +++ b/Assets/GAME/Prefabs/UI/Text.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 468498af192084144bafa6de7497eef5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GAME/Prefabs/UI/Text/PresenceFeedEntry.prefab b/Assets/GAME/Prefabs/UI/Text/PresenceFeedEntry.prefab new file mode 100644 index 00000000..d4a57671 --- /dev/null +++ b/Assets/GAME/Prefabs/UI/Text/PresenceFeedEntry.prefab @@ -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 diff --git a/Assets/GAME/Prefabs/UI/Text/PresenceFeedEntry.prefab.meta b/Assets/GAME/Prefabs/UI/Text/PresenceFeedEntry.prefab.meta new file mode 100644 index 00000000..d1fd2aeb --- /dev/null +++ b/Assets/GAME/Prefabs/UI/Text/PresenceFeedEntry.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dc9aaa105205d6e48bb40dd907fef4fe +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GAME/Scenes/TestScene.unity b/Assets/GAME/Scenes/TestScene.unity index 93105761..12be7f51 100644 Binary files a/Assets/GAME/Scenes/TestScene.unity and b/Assets/GAME/Scenes/TestScene.unity differ diff --git a/Assets/GAME/Script/Network/NetworkSessionManager.cs b/Assets/GAME/Script/Network/NetworkSessionManager.cs index 57904c39..6eb01dd1 100644 --- a/Assets/GAME/Script/Network/NetworkSessionManager.cs +++ b/Assets/GAME/Script/Network/NetworkSessionManager.cs @@ -38,6 +38,10 @@ namespace Ashwild.Network [Tooltip("The networked player prefab (NetworkObject + PlayerNetworkController).")] [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")] [Tooltip("Logs every host/join/connection/spawn step to the console.")] [SerializeField] private bool verboseLogging = true; @@ -56,6 +60,12 @@ namespace Ashwild.Network /// public string GameSceneName => gameSceneName; + /// + /// 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. + /// + public int MaxPlayers => maxPlayers; + /// /// Cached FishNet manager resolved from the active scene. /// @@ -262,6 +272,8 @@ namespace Ashwild.Network if (args.ConnectionState == LocalConnectionState.Started) { + ApplyPlayerCap(); + string code = GetLocalSteamCode(); PlayerEvents.RaiseSessionStarted(code); Log($"✅ Session créée ! Code à partager = {code}"); @@ -430,6 +442,20 @@ namespace Ashwild.Network PlayerEvents.RaiseMenuReady(); } + /// + /// Caps the transport at - 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. + /// + 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)."); + } + /// /// Loads the gameplay scene as a global networked scene, replacing the menu. /// diff --git a/Assets/GAME/Script/Network/PlayerBodyVisibility.cs b/Assets/GAME/Script/Network/PlayerBodyVisibility.cs new file mode 100644 index 00000000..04de8dfb --- /dev/null +++ b/Assets/GAME/Script/Network/PlayerBodyVisibility.cs @@ -0,0 +1,76 @@ +using FishNet.Object; +using UnityEngine; + +namespace Ashwild.Network +{ + /// + /// 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. + /// + [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 + + /// + /// 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. + /// + 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 + + /// + /// 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. + /// + private static void SetLayerRecursively(GameObject root, int layer) + { + root.layer = layer; + foreach (Transform child in root.transform) + SetLayerRecursively(child.gameObject, layer); + } + + #endregion + } +} diff --git a/Assets/GAME/Script/Network/PlayerBodyVisibility.cs.meta b/Assets/GAME/Script/Network/PlayerBodyVisibility.cs.meta new file mode 100644 index 00000000..4d1ad949 --- /dev/null +++ b/Assets/GAME/Script/Network/PlayerBodyVisibility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 23848f2e7f7ecba4791aa89d5344b3d0 \ No newline at end of file diff --git a/Assets/GAME/Script/Network/SessionPresenceEvents.cs b/Assets/GAME/Script/Network/SessionPresenceEvents.cs new file mode 100644 index 00000000..c465e4b0 --- /dev/null +++ b/Assets/GAME/Script/Network/SessionPresenceEvents.cs @@ -0,0 +1,42 @@ +using System; + +namespace Ashwild.Network +{ + /// + /// 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. + /// + public static class SessionPresenceEvents + { + #region Events + + /// + /// A remote player just joined the session — carries their display name. + /// + public static event Action PlayerJoined; + + /// + /// A remote player just left the session — carries their display name. + /// + public static event Action PlayerLeft; + + #endregion + + #region Raisers + + /// + /// Announces that a remote player joined, to whoever is listening on this client. + /// + public static void RaisePlayerJoined(string playerName) => PlayerJoined?.Invoke(playerName); + + /// + /// Announces that a remote player left, to whoever is listening on this client. + /// + public static void RaisePlayerLeft(string playerName) => PlayerLeft?.Invoke(playerName); + + #endregion + } +} diff --git a/Assets/GAME/Script/Network/SessionPresenceEvents.cs.meta b/Assets/GAME/Script/Network/SessionPresenceEvents.cs.meta new file mode 100644 index 00000000..51447a03 --- /dev/null +++ b/Assets/GAME/Script/Network/SessionPresenceEvents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: db47bb9b3f6b5c747ae09b007c8aebf0 \ No newline at end of file diff --git a/Assets/GAME/Script/Network/SessionPresenceNotifier.cs b/Assets/GAME/Script/Network/SessionPresenceNotifier.cs new file mode 100644 index 00000000..5ab3ee43 --- /dev/null +++ b/Assets/GAME/Script/Network/SessionPresenceNotifier.cs @@ -0,0 +1,97 @@ +using FishNet.Object; +using FishNet.Object.Synchronizing; +using Steamworks; +using UnityEngine; + +namespace Ashwild.Network +{ + /// + /// 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. + /// + [DisallowMultipleComponent] + public class SessionPresenceNotifier : NetworkBehaviour + { + #region Networked State + + /// + /// 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. + /// + private readonly SyncVar displayName = new SyncVar(); + + #endregion + + #region Network Lifecycle + + /// + /// On the owning client, publishes the local Steam persona name so the server can announce the join. + /// + 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()); + } + + /// + /// 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. + /// + 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 + + /// + /// Server-side: records the owner's name and announces the join once to the current observers. + /// + [ServerRpc] + private void SubmitPresenceServerRpc(string persona) + { + displayName.Value = persona; + AnnounceJoinObserversRpc(persona); + } + + /// + /// 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. + /// + [ObserversRpc] + private void AnnounceJoinObserversRpc(string persona) + { + if (base.IsOwner) return; + SessionPresenceEvents.RaisePlayerJoined(persona); + } + + #endregion + } +} diff --git a/Assets/GAME/Script/Network/SessionPresenceNotifier.cs.meta b/Assets/GAME/Script/Network/SessionPresenceNotifier.cs.meta new file mode 100644 index 00000000..f96f288d --- /dev/null +++ b/Assets/GAME/Script/Network/SessionPresenceNotifier.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8809701d89694f5488cc03b300f8dc09 \ No newline at end of file diff --git a/Assets/GAME/Script/Network/SteamInviteService.cs b/Assets/GAME/Script/Network/SteamInviteService.cs index 8a1fd3ea..fb6aca8c 100644 --- a/Assets/GAME/Script/Network/SteamInviteService.cs +++ b/Assets/GAME/Script/Network/SteamInviteService.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using FishNet; +using FishNet.Managing; using Steamworks; using UnityEngine; using Ashwild.Player; @@ -8,13 +10,18 @@ namespace Ashwild.Network { /// /// 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 - /// their current session (the in-game pause menu "Invite" button calls OpenInviteOverlay). + /// • SEND — while in a session, keeps a private "carrier" Steam lobby (one per local player, all + /// 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 - /// friends see "Join Game" and invites carry the code that JoinSession understands. - /// • RECEIVE — listens for Steam's GameRichPresenceJoinRequested callback (fired when a friend - /// accepts our invite or clicks "Join Game") and raises PlayerEvents.InviteReceived so the - /// menu can pop the invitation card. Also resolves friend avatars (async via Steam). + /// friends see "Join Game" in their friends list and that path carries the code JoinSession reads. + /// • RECEIVE — routes Steam's join paths to match the player's intent: + /// – LobbyInvite_t (invitation just ARRIVED) → raises PlayerEvents.InviteReceived so the menu + /// 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. /// [DisallowMultipleComponent] @@ -23,10 +30,16 @@ namespace Ashwild.Network #region Constants /// - /// 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. /// private const string ConnectKey = "connect"; + /// + /// Fallback member cap for the carrier lobby when the session manager is unavailable. + /// + private const int FallbackMemberLimit = 6; + #endregion #region State @@ -37,15 +50,41 @@ namespace Ashwild.Network public static SteamInviteService Instance { get; private set; } /// - /// 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. /// private Callback joinRequestedCallback; + /// + /// Steam callback fired when the local player clicks "Join" on a lobby invite inside Steam. + /// + private Callback lobbyJoinRequestedCallback; + + /// + /// Steam callback fired the moment a friend's lobby invitation ARRIVES (before any accept). + /// + private Callback lobbyInviteCallback; + /// /// Steam callback fired when an avatar image finishes downloading from Steam's servers. /// private Callback avatarLoadedCallback; + /// + /// Async result of CreateLobby — writes the connect code into the new lobby once it exists. + /// + private CallResult lobbyCreatedResult; + + /// + /// 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. + /// + private CSteamID currentLobby; + + /// + /// Connect code to stamp onto the carrier lobby once CreateLobby completes. + /// + private string pendingLobbyCode; + /// /// Avatar requests still waiting on Steam to finish downloading the image, by SteamID64. /// @@ -86,7 +125,8 @@ namespace Ashwild.Network } /// - /// 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. /// private void OnDisable() { @@ -94,10 +134,18 @@ namespace Ashwild.Network PlayerEvents.SessionJoined -= HandleSessionJoined; PlayerEvents.SessionStopped -= HandleSessionStopped; + LeaveCarrierLobby(); + joinRequestedCallback?.Dispose(); + lobbyJoinRequestedCallback?.Dispose(); + lobbyInviteCallback?.Dispose(); avatarLoadedCallback?.Dispose(); + lobbyCreatedResult?.Dispose(); joinRequestedCallback = null; + lobbyJoinRequestedCallback = null; + lobbyInviteCallback = null; avatarLoadedCallback = null; + lobbyCreatedResult = null; callbacksReady = false; pendingAvatars.Clear(); @@ -112,10 +160,11 @@ namespace Ashwild.Network } /// - /// Clears the singleton reference. + /// Clears the singleton reference and leaves any carrier lobby still open. /// private void OnDestroy() { + LeaveCarrierLobby(); if (Instance == this) Instance = null; } @@ -124,8 +173,10 @@ namespace Ashwild.Network #region Public API /// - /// Opens the Steam overlay friends list so the player can invite a friend to the current - /// session. Requires being in a session (host or client) so there is a code to share. + /// Opens the Steam overlay so the player can invite a friend to the current session. Prefers + /// 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. /// public void OpenInviteOverlay() { @@ -142,7 +193,10 @@ namespace Ashwild.Network return; } - SteamFriends.ActivateGameOverlayInviteDialogConnectString(code); + if (currentLobby.IsValid()) + SteamFriends.ActivateGameOverlayInviteDialog(currentLobby); + else + SteamFriends.ActivateGameOverlayInviteDialogConnectString(code); } /// @@ -181,7 +235,44 @@ namespace Ashwild.Network #region Steam Callbacks /// - /// 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. + /// + 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); + } + + /// + /// The player clicked "Join" on a lobby invite inside Steam — an explicit intent, so we join + /// the session directly and skip the invitation card. + /// + 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); + } + + /// + /// A friend clicked "Join Game" via our Rich Presence connect string — an explicit intent, so + /// we join directly rather than popping the invitation card. /// private void OnJoinRequested(GameRichPresenceJoinRequested_t cb) { @@ -192,13 +283,24 @@ namespace Ashwild.Network return; } - ulong inviterId = cb.m_steamIDFriend.m_SteamID; - string inviterName = cb.m_steamIDFriend.IsValid() - ? SteamFriends.GetFriendPersonaName(cb.m_steamIDFriend) - : "Un ami"; + JoinDirect(connect); + } - Debug.Log($"[SteamInvite] Invitation reçue de {inviterName} (connect={connect}).", this); - 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. + /// + 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); } /// @@ -218,22 +320,34 @@ namespace Ashwild.Network #region Event Handlers /// - /// 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. /// - private void HandleSessionOnline(string code) => PublishConnect(code); + private void HandleSessionOnline(string code) + { + PublishConnect(code); + CreateCarrierLobby(code); + } /// - /// 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. /// - private void HandleSessionJoined() => PublishConnect(PlayerEvents.SessionCode); + private void HandleSessionJoined() + { + string code = PlayerEvents.SessionCode; + PublishConnect(code); + CreateCarrierLobby(code); + } /// - /// Session ended — remove the Rich Presence so we stop advertising as joinable. + /// Session ended — stop advertising as joinable and tear down the carrier lobby. /// private void HandleSessionStopped() { if (SteamManager.Initialized) SteamFriends.SetRichPresence(ConnectKey, null); + + LeaveCarrierLobby(); } #endregion @@ -248,7 +362,10 @@ namespace Ashwild.Network if (callbacksReady || !SteamManager.Initialized) return; joinRequestedCallback = Callback.Create(OnJoinRequested); + lobbyJoinRequestedCallback = Callback.Create(OnLobbyJoinRequested); + lobbyInviteCallback = Callback.Create(OnLobbyInvite); avatarLoadedCallback = Callback.Create(OnAvatarLoaded); + lobbyCreatedResult = CallResult.Create(OnLobbyCreated); callbacksReady = true; } @@ -261,6 +378,88 @@ namespace Ashwild.Network SteamFriends.SetRichPresence(ConnectKey, code); } + /// + /// 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. + /// + 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); + } + + /// + /// Leaves and forgets the carrier lobby, if one is open. + /// + private void LeaveCarrierLobby() + { + pendingLobbyCode = null; + if (!currentLobby.IsValid()) return; + + if (SteamManager.Initialized) + SteamMatchmaking.LeaveLobby(currentLobby); + currentLobby = CSteamID.Nil; + } + + /// + /// 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). + /// + private string ResolveConnectFromFriend(CSteamID friend) + { + if (!SteamManager.Initialized || !friend.IsValid()) return string.Empty; + return SteamFriends.GetFriendRichPresence(friend, ConnectKey); + } + + /// + /// 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. + /// + 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); + } + + /// + /// True when a client or server connection is already up, so we shouldn't start a fresh join. + /// + private bool IsAlreadyInSession() + { + NetworkManager nm = InstanceFinder.NetworkManager; + if (nm == null) return false; + return nm.ClientManager.Started || nm.ServerManager.Started; + } + #endregion } } diff --git a/Assets/GAME/Script/Player/Animation.meta b/Assets/GAME/Script/Player/Animation.meta new file mode 100644 index 00000000..a83d70a2 --- /dev/null +++ b/Assets/GAME/Script/Player/Animation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7bcd5cb7e1311614a8607e68424e119f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GAME/Script/Player/Animation/PlayerAnimatorDriver.cs b/Assets/GAME/Script/Player/Animation/PlayerAnimatorDriver.cs new file mode 100644 index 00000000..7d15dc75 --- /dev/null +++ b/Assets/GAME/Script/Player/Animation/PlayerAnimatorDriver.cs @@ -0,0 +1,185 @@ +using UnityEngine; + +namespace Ashwild.Player +{ + /// + /// 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. + /// + [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 + + /// + /// Caches parameter hashes (0 for blank names, which are then skipped) and defaults the + /// orientation reference so local move direction works without extra wiring. + /// + 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; + } + + /// + /// 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. + /// + 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); + } + + /// + /// Unsubscribes — mirrors OnEnable exactly. + /// + private void OnDisable() + { + PlayerEvents.VelocityChanged -= HandleVelocityChanged; + PlayerEvents.GroundedChanged -= HandleGroundedChanged; + PlayerEvents.CrouchChanged -= HandleCrouchChanged; + PlayerEvents.SprintChanged -= HandleSprintChanged; + PlayerEvents.Jumped -= HandleJumped; + PlayerEvents.AttackSwung -= HandleAttackSwung; + PlayerEvents.LandingStunStarted -= HandleLandingStunStarted; + } + + /// + /// Pushes the smoothed movement floats every frame so blend trees ease between poses + /// instead of snapping when velocity changes. + /// + 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 + + /// + /// Caches the planar (horizontal) velocity that Update turns into blend-tree floats. + /// + 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); + + /// + /// A landing stun begins on a hard touchdown — the cue for a landing animation. + /// + private void HandleLandingStunStarted(float duration) => SetTrigger(landId); + + #endregion + + #region Internal Helpers + + /// + /// Hashes a parameter name, returning 0 for a blank name so the setters can skip it. + /// + 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 + } +} diff --git a/Assets/GAME/Script/Player/Animation/PlayerAnimatorDriver.cs.meta b/Assets/GAME/Script/Player/Animation/PlayerAnimatorDriver.cs.meta new file mode 100644 index 00000000..0dd34c76 --- /dev/null +++ b/Assets/GAME/Script/Player/Animation/PlayerAnimatorDriver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8c1abd96334ddec4f8abf1ccdde057f9 \ No newline at end of file diff --git a/Assets/GAME/Script/Player/Events/PlayerEvents.cs b/Assets/GAME/Script/Player/Events/PlayerEvents.cs index 484d1005..29c16e72 100644 --- a/Assets/GAME/Script/Player/Events/PlayerEvents.cs +++ b/Assets/GAME/Script/Player/Events/PlayerEvents.cs @@ -47,6 +47,7 @@ namespace Ashwild.Player public static event Action SprintHeld; public static event Action CrouchPressed; 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 DropPressed; public static event Action InventoryTogglePressed; @@ -115,6 +116,12 @@ namespace Ashwild.Player public static event Action EquipAnimComplete; public static event Action UnequipAnimComplete; + // ============================================================ + // Building events + // ============================================================ + + public static event Action BuildMenuToggleRequested; // the build hammer asks to open/close the construction menu + // ============================================================ // Cooking events // ============================================================ @@ -166,6 +173,7 @@ namespace Ashwild.Player public static void RaiseSprintHeld(bool b) { Log(nameof(SprintHeld)); SprintHeld?.Invoke(b); } public static void RaiseCrouchPressed() { Log(nameof(CrouchPressed)); CrouchPressed?.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 RaiseDropPressed() { Log(nameof(DropPressed)); DropPressed?.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 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 RaiseFoodCookedReady(ItemData cooked) { Log(nameof(FoodCookedReady)); FoodCookedReady?.Invoke(cooked); } public static void RaiseFuelAdded(ItemData fuel) { Log(nameof(FuelAdded)); FuelAdded?.Invoke(fuel); } diff --git a/Assets/GAME/Script/Player/HeldItems/BuildHammerBehaviour.cs b/Assets/GAME/Script/Player/HeldItems/BuildHammerBehaviour.cs new file mode 100644 index 00000000..c4c2c5bd --- /dev/null +++ b/Assets/GAME/Script/Player/HeldItems/BuildHammerBehaviour.cs @@ -0,0 +1,85 @@ +using UnityEngine; +using Ashwild.Inventory; + +namespace Ashwild.Player +{ + /// + /// 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. + /// + [DisallowMultipleComponent] + public class BuildHammerBehaviour : MonoBehaviour, IHeldItemBehaviour + { + #region Serialized Fields + + [Header("Cooldown")] + /// + /// Minimum time, in seconds, between two menu toggles so a held right-click + /// does not flicker the menu open and shut. + /// + [SerializeField] private float toggleCooldown = 0.25f; + + #endregion + + #region State + + /// + /// Earliest time, in seconds, the next menu toggle is allowed. + /// + private float nextToggleTime; + + #endregion + + #region Unity Lifecycle + + /// + /// Subscribes to the secondary-use input for as long as the hammer is held. + /// + private void OnEnable() + { + PlayerEvents.SecondaryUsePressed += HandleSecondaryUse; + } + + /// + /// Unsubscribes when the hammer is put away (the prefab is destroyed). + /// + private void OnDisable() + { + PlayerEvents.SecondaryUsePressed -= HandleSecondaryUse; + } + + #endregion + + #region IHeldItemBehaviour + + /// + /// 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. + /// + public void Setup(HeldItemContext context, ItemData item) + { + } + + #endregion + + #region Event Handlers + + /// + /// Requests the construction menu to open/close on right-click, gated by lock and + /// cooldown. The UI decides whether that opens or closes it. + /// + private void HandleSecondaryUse() + { + if (PlayerEvents.InputLocked) return; + if (Time.time < nextToggleTime) return; + + nextToggleTime = Time.time + toggleCooldown; + PlayerEvents.RaiseBuildMenuToggleRequested(); + } + + #endregion + } +} diff --git a/Assets/GAME/Script/Player/HeldItems/BuildHammerBehaviour.cs.meta b/Assets/GAME/Script/Player/HeldItems/BuildHammerBehaviour.cs.meta new file mode 100644 index 00000000..acf799a9 --- /dev/null +++ b/Assets/GAME/Script/Player/HeldItems/BuildHammerBehaviour.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59ffa82caa5b57841a87a6f1e5d8839d \ No newline at end of file diff --git a/Assets/GAME/Script/Player/Input/InputManager.cs b/Assets/GAME/Script/Player/Input/InputManager.cs index 71f1ef23..04c69ce2 100644 --- a/Assets/GAME/Script/Player/Input/InputManager.cs +++ b/Assets/GAME/Script/Player/Input/InputManager.cs @@ -199,6 +199,7 @@ namespace Ashwild.Player WireHold(playerMap, "Sprint", PlayerEvents.RaiseSprintHeld); WireButton(playerMap, "Crouch", PlayerEvents.RaiseCrouchPressed); WireButton(playerMap, "Attack", PlayerEvents.RaiseAttackPressed); + WireButton(playerMap, "SecondaryUse", PlayerEvents.RaiseSecondaryUsePressed); WireButton(playerMap, "Interact", PlayerEvents.RaiseInteractPressed); WireButton(playerMap, "Drop", PlayerEvents.RaiseDropPressed); WireButton(playerMap, "Inventory", PlayerEvents.RaiseInventoryTogglePressed); diff --git a/Assets/GAME/Script/UI/PlayerPresenceFeedUI.cs b/Assets/GAME/Script/UI/PlayerPresenceFeedUI.cs new file mode 100644 index 00000000..39fd2ff2 --- /dev/null +++ b/Assets/GAME/Script/UI/PlayerPresenceFeedUI.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using UnityEngine; +using Ashwild.Network; +using Ashwild.Player; + +namespace Ashwild.UI +{ + /// + /// 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. + /// + [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 + + /// + /// Currently displayed lines, oldest first — trimmed from the front when over the cap. + /// + private readonly List activeEntries = new List(); + + /// + /// True once the session is tearing down, so the despawn-driven "left" storm is ignored. + /// + private bool suppressed; + + #endregion + + #region Unity Lifecycle + + /// + /// Subscribes to presence notifications and the teardown signals that mute the feed. + /// + private void OnEnable() + { + SessionPresenceEvents.PlayerJoined += HandlePlayerJoined; + SessionPresenceEvents.PlayerLeft += HandlePlayerLeft; + PlayerEvents.ReturningToMenu += HandleTeardown; + PlayerEvents.SessionStopped += HandleTeardown; + } + + /// + /// Unsubscribes — mirrors OnEnable exactly. + /// + private void OnDisable() + { + SessionPresenceEvents.PlayerJoined -= HandlePlayerJoined; + SessionPresenceEvents.PlayerLeft -= HandlePlayerLeft; + PlayerEvents.ReturningToMenu -= HandleTeardown; + PlayerEvents.SessionStopped -= HandleTeardown; + } + + #endregion + + #region Event Handlers + + /// + /// Shows a green arrival line for the named player. + /// + private void HandlePlayerJoined(string playerName) => Push(string.Format(joinFormat, playerName), joinColor); + + /// + /// Shows an orange departure line for the named player. + /// + private void HandlePlayerLeft(string playerName) => Push(string.Format(leaveFormat, playerName), leaveColor); + + /// + /// Mutes the feed for the rest of its lifetime once the session is being torn down. + /// + private void HandleTeardown() => suppressed = true; + + #endregion + + #region Internal Helpers + + /// + /// 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). + /// + 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 + } +} diff --git a/Assets/GAME/Script/UI/PlayerPresenceFeedUI.cs.meta b/Assets/GAME/Script/UI/PlayerPresenceFeedUI.cs.meta new file mode 100644 index 00000000..4e2537ea --- /dev/null +++ b/Assets/GAME/Script/UI/PlayerPresenceFeedUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e34a77305857ab84a87431aca9903af0 \ No newline at end of file diff --git a/Assets/GAME/Script/UI/PresenceFeedEntry.cs b/Assets/GAME/Script/UI/PresenceFeedEntry.cs new file mode 100644 index 00000000..6789c780 --- /dev/null +++ b/Assets/GAME/Script/UI/PresenceFeedEntry.cs @@ -0,0 +1,81 @@ +using UnityEngine; +using TMPro; +using DG.Tweening; + +namespace Ashwild.UI +{ + /// + /// 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. + /// + [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 + + /// + /// The full lifecycle tween (fade in → hold → fade out → destroy), killed on teardown. + /// + private Sequence lifecycle; + + #endregion + + #region Unity Lifecycle + + /// + /// Caches the CanvasGroup when it wasn't wired in the inspector. + /// + private void Awake() + { + if (canvasGroup == null) canvasGroup = GetComponent(); + } + + /// + /// Kills the lifecycle tween so it never targets a destroyed object. + /// + private void OnDestroy() + { + lifecycle?.Kill(); + } + + #endregion + + #region Public API + + /// + /// Fills in the message and colour, then runs the fade-in → hold → fade-out → destroy sequence. + /// Safe to call once right after instantiation. + /// + 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 + } +} diff --git a/Assets/GAME/Script/UI/PresenceFeedEntry.cs.meta b/Assets/GAME/Script/UI/PresenceFeedEntry.cs.meta new file mode 100644 index 00000000..479663bb --- /dev/null +++ b/Assets/GAME/Script/UI/PresenceFeedEntry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4c87382f506a62e489d4ea7a432accac \ No newline at end of file diff --git a/Packages/manifest.json b/Packages/manifest.json index 0a017d96..3c1d1f0b 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -8,6 +8,7 @@ "com.unity.ide.visualstudio": "2.0.26", "com.unity.inputsystem": "1.19.0", "com.unity.multiplayer.center": "1.0.1", + "com.unity.recorder": "5.1.6", "com.unity.render-pipelines.universal": "17.3.0", "com.unity.test-framework": "1.6.0", "com.unity.timeline": "1.8.12", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index f9dd6321..63b52eaf 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -15,6 +15,15 @@ }, "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": { "version": "1.8.29", "depth": 2, @@ -34,7 +43,7 @@ }, "com.unity.collections": { "version": "2.6.6", - "depth": 2, + "depth": 1, "source": "registry", "dependencies": { "com.unity.burst": "1.8.28", @@ -102,11 +111,22 @@ }, "com.unity.nuget.mono-cecil": { "version": "1.11.6", - "depth": 3, + "depth": 2, "source": "registry", "dependencies": {}, "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": { "version": "17.3.0", "depth": 1, @@ -167,7 +187,7 @@ }, "com.unity.test-framework.performance": { "version": "3.5.0", - "depth": 3, + "depth": 2, "source": "registry", "dependencies": { "com.unity.test-framework": "1.1.33", diff --git a/ProjectSettings/TagManager.asset b/ProjectSettings/TagManager.asset index 9009d417..7fe9a207 100644 --- a/ProjectSettings/TagManager.asset +++ b/ProjectSettings/TagManager.asset @@ -3,7 +3,9 @@ --- !u!78 &1 TagManager: serializedVersion: 3 - tags: [] + tags: + - OreNode + - OrePickup layers: - Default - TransparentFX @@ -17,7 +19,7 @@ TagManager: - Build - Player - Pickable - - + - LocalBody - - -