From c4a4549206f99be5fc8868e5d94409467135249b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 01:25:42 +0000 Subject: [PATCH 1/3] Initial plan From 9f17a7d75cdae4e523feb4fd88a1cd9f24f43f01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 01:28:11 +0000 Subject: [PATCH 2/3] Add Phase 1 and Phase 2 Unity networking/player foundation Agent-Logs-Url: https://github.com/t7451/Newhorror/sessions/d36d2473-0405-4256-b912-25c1601498c3 Co-authored-by: ksksrbiz-arch <240277128+ksksrbiz-arch@users.noreply.github.com> --- Assets/HorrorCoopGame/Prefabs/.gitkeep | 0 Assets/HorrorCoopGame/Scenes/.gitkeep | 0 .../HorrorCoopGame/ScriptableObjects/.gitkeep | 0 Assets/HorrorCoopGame/Scripts/AI/.gitkeep | 0 .../HorrorCoopGame/Scripts/Building/.gitkeep | 0 .../Scripts/Interaction/.gitkeep | 0 .../Scripts/Networking/NetworkMenuUI.cs | 88 ++++++++++ .../Networking/NetworkWebSocketSetup.cs | 41 +++++ .../Scripts/Networking/RelayManager.cs | 166 ++++++++++++++++++ .../Scripts/Player/PlayerController.cs | 154 ++++++++++++++++ .../Scripts/Player/PlayerStats.cs | 99 +++++++++++ Assets/HorrorCoopGame/TouchControls/.gitkeep | 0 Assets/HorrorCoopGame/UI/.gitkeep | 0 README.md | 51 +++++- 14 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 Assets/HorrorCoopGame/Prefabs/.gitkeep create mode 100644 Assets/HorrorCoopGame/Scenes/.gitkeep create mode 100644 Assets/HorrorCoopGame/ScriptableObjects/.gitkeep create mode 100644 Assets/HorrorCoopGame/Scripts/AI/.gitkeep create mode 100644 Assets/HorrorCoopGame/Scripts/Building/.gitkeep create mode 100644 Assets/HorrorCoopGame/Scripts/Interaction/.gitkeep create mode 100644 Assets/HorrorCoopGame/Scripts/Networking/NetworkMenuUI.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Networking/NetworkWebSocketSetup.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Networking/RelayManager.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Player/PlayerController.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Player/PlayerStats.cs create mode 100644 Assets/HorrorCoopGame/TouchControls/.gitkeep create mode 100644 Assets/HorrorCoopGame/UI/.gitkeep diff --git a/Assets/HorrorCoopGame/Prefabs/.gitkeep b/Assets/HorrorCoopGame/Prefabs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Assets/HorrorCoopGame/Scenes/.gitkeep b/Assets/HorrorCoopGame/Scenes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Assets/HorrorCoopGame/ScriptableObjects/.gitkeep b/Assets/HorrorCoopGame/ScriptableObjects/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Assets/HorrorCoopGame/Scripts/AI/.gitkeep b/Assets/HorrorCoopGame/Scripts/AI/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Assets/HorrorCoopGame/Scripts/Building/.gitkeep b/Assets/HorrorCoopGame/Scripts/Building/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Assets/HorrorCoopGame/Scripts/Interaction/.gitkeep b/Assets/HorrorCoopGame/Scripts/Interaction/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Assets/HorrorCoopGame/Scripts/Networking/NetworkMenuUI.cs b/Assets/HorrorCoopGame/Scripts/Networking/NetworkMenuUI.cs new file mode 100644 index 0000000..3655eb4 --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Networking/NetworkMenuUI.cs @@ -0,0 +1,88 @@ +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace HorrorCoopGame.Networking +{ + public sealed class NetworkMenuUI : MonoBehaviour + { + [SerializeField] private Button hostButton; + [SerializeField] private Button joinButton; + [SerializeField] private TMP_InputField joinCodeInput; + [SerializeField] private TextMeshProUGUI statusText; + + private void Awake() + { + if (hostButton != null) + { + hostButton.onClick.AddListener(OnHostClicked); + } + + if (joinButton != null) + { + joinButton.onClick.AddListener(OnJoinClicked); + } + } + + private void OnDestroy() + { + if (hostButton != null) + { + hostButton.onClick.RemoveListener(OnHostClicked); + } + + if (joinButton != null) + { + joinButton.onClick.RemoveListener(OnJoinClicked); + } + } + + private async void OnHostClicked() + { + if (statusText == null || RelayManager.Instance == null) + { + return; + } + + statusText.text = "Creating relay room..."; + + string joinCode = await RelayManager.Instance.CreateRelayAsync(); + if (string.IsNullOrEmpty(joinCode)) + { + statusText.text = "Failed to create room."; + return; + } + + GUIUtility.systemCopyBuffer = joinCode; + statusText.text = $"Room created. Code: {joinCode}"; + gameObject.SetActive(false); + } + + private async void OnJoinClicked() + { + if (statusText == null || RelayManager.Instance == null) + { + return; + } + + string joinCode = joinCodeInput != null ? joinCodeInput.text.Trim() : string.Empty; + if (string.IsNullOrWhiteSpace(joinCode)) + { + statusText.text = "Enter a join code."; + return; + } + + statusText.text = "Joining relay room..."; + + bool joined = await RelayManager.Instance.JoinRelayAsync(joinCode); + if (!joined) + { + statusText.text = "Join failed."; + return; + } + + statusText.text = "Connected."; + gameObject.SetActive(false); + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Networking/NetworkWebSocketSetup.cs b/Assets/HorrorCoopGame/Scripts/Networking/NetworkWebSocketSetup.cs new file mode 100644 index 0000000..d6657e4 --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Networking/NetworkWebSocketSetup.cs @@ -0,0 +1,41 @@ +using Unity.Netcode; +using Unity.Netcode.Transports.UTP; +using UnityEngine; + +namespace HorrorCoopGame.Networking +{ + [RequireComponent(typeof(NetworkManager))] + [DisallowMultipleComponent] + public sealed class NetworkWebSocketSetup : MonoBehaviour + { + [SerializeField] private NetworkManager networkManager; + + private void Reset() + { + networkManager = GetComponent(); + } + + private void Awake() + { + if (networkManager == null) + { + networkManager = GetComponent(); + } + + if (networkManager == null) + { + Debug.LogError("NetworkManager is required on this GameObject."); + return; + } + + UnityTransport transport = networkManager.NetworkConfig.NetworkTransport as UnityTransport; + if (transport == null) + { + Debug.LogError("UnityTransport is required for Relay + WebSocket support."); + return; + } + + transport.UseWebSockets = true; + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Networking/RelayManager.cs b/Assets/HorrorCoopGame/Scripts/Networking/RelayManager.cs new file mode 100644 index 0000000..7eadd59 --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Networking/RelayManager.cs @@ -0,0 +1,166 @@ +using System; +using System.Threading.Tasks; +using Unity.Netcode; +using Unity.Netcode.Transports.UTP; +using Unity.Networking.Transport.Relay; +using Unity.Services.Authentication; +using Unity.Services.Core; +using Unity.Services.Relay; +using Unity.Services.Relay.Models; +using UnityEngine; + +namespace HorrorCoopGame.Networking +{ + [DisallowMultipleComponent] + public sealed class RelayManager : MonoBehaviour + { + public static RelayManager Instance { get; private set; } + + [SerializeField] private int maxConnections = 4; + [SerializeField] private NetworkManager networkManager; + + private bool isInitialized; + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(gameObject); + return; + } + + Instance = this; + DontDestroyOnLoad(gameObject); + + if (networkManager == null) + { + networkManager = NetworkManager.Singleton; + } + } + + private async void Start() + { + await InitializeServicesAsync(); + } + + public async Task CreateRelayAsync() + { + if (!await InitializeServicesAsync()) + { + return string.Empty; + } + + if (networkManager == null) + { + Debug.LogError("NetworkManager reference is missing."); + return string.Empty; + } + + try + { + Allocation allocation = await RelayService.Instance.CreateAllocationAsync(maxConnections); + string joinCode = await RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId); + + UnityTransport transport = GetTransportOrLogError(); + if (transport == null) + { + return string.Empty; + } + + transport.UseWebSockets = true; + transport.SetRelayServerData(new RelayServerData(allocation, "wss")); + + if (!networkManager.StartHost()) + { + Debug.LogError("Failed to start host."); + return string.Empty; + } + + return joinCode; + } + catch (Exception exception) + { + Debug.LogError($"CreateRelayAsync failed: {exception}"); + return string.Empty; + } + } + + public async Task JoinRelayAsync(string joinCode) + { + if (string.IsNullOrWhiteSpace(joinCode)) + { + Debug.LogWarning("Join code was empty."); + return false; + } + + if (!await InitializeServicesAsync()) + { + return false; + } + + if (networkManager == null) + { + Debug.LogError("NetworkManager reference is missing."); + return false; + } + + try + { + JoinAllocation joinAllocation = await RelayService.Instance.JoinAllocationAsync(joinCode.Trim()); + + UnityTransport transport = GetTransportOrLogError(); + if (transport == null) + { + return false; + } + + transport.UseWebSockets = true; + transport.SetRelayServerData(new RelayServerData(joinAllocation, "wss")); + + return networkManager.StartClient(); + } + catch (Exception exception) + { + Debug.LogError($"JoinRelayAsync failed for code '{joinCode}': {exception}"); + return false; + } + } + + private UnityTransport GetTransportOrLogError() + { + UnityTransport transport = networkManager.NetworkConfig.NetworkTransport as UnityTransport; + if (transport == null) + { + Debug.LogError("NetworkConfig.NetworkTransport must be UnityTransport."); + } + + return transport; + } + + private async Task InitializeServicesAsync() + { + if (isInitialized) + { + return true; + } + + try + { + await UnityServices.InitializeAsync(); + + if (!AuthenticationService.Instance.IsSignedIn) + { + await AuthenticationService.Instance.SignInAnonymouslyAsync(); + } + + isInitialized = true; + return true; + } + catch (Exception exception) + { + Debug.LogError($"Unity Services initialization failed: {exception}"); + return false; + } + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Player/PlayerController.cs b/Assets/HorrorCoopGame/Scripts/Player/PlayerController.cs new file mode 100644 index 0000000..7455dff --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Player/PlayerController.cs @@ -0,0 +1,154 @@ +using Unity.Netcode; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace HorrorCoopGame.Player +{ + [RequireComponent(typeof(CharacterController))] + [RequireComponent(typeof(PlayerInput))] + public sealed class PlayerController : NetworkBehaviour + { + [Header("Movement")] + [SerializeField] private float walkSpeed = 4f; + [SerializeField] private float sprintSpeed = 7f; + [SerializeField] private float gravity = -19.62f; + [SerializeField] private float jumpHeight = 1.2f; + + [Header("Look")] + [SerializeField] private Transform cameraPivot; + [SerializeField] private float mouseLookSensitivity = 0.12f; + [SerializeField] private float touchLookSensitivity = 0.08f; + [SerializeField] private float minPitch = -85f; + [SerializeField] private float maxPitch = 85f; + + [Header("Touch UI")] + [SerializeField] private Canvas touchControlsCanvas; + + private CharacterController characterController; + private PlayerInput playerInput; + + private Vector2 moveInput; + private Vector2 lookInput; + private bool sprintPressed; + private bool jumpQueued; + + private float verticalVelocity; + private float pitch; + private bool isMobile; + + public override void OnNetworkSpawn() + { + characterController = GetComponent(); + playerInput = GetComponent(); + + if (!IsOwner) + { + if (cameraPivot != null) + { + cameraPivot.gameObject.SetActive(false); + } + + if (playerInput != null) + { + playerInput.enabled = false; + } + + enabled = false; + return; + } + + isMobile = Application.isMobilePlatform; + + if (touchControlsCanvas != null) + { + touchControlsCanvas.enabled = isMobile; + } + + if (!isMobile) + { + Cursor.lockState = CursorLockMode.Locked; + Cursor.visible = false; + } + } + + public override void OnNetworkDespawn() + { + if (!isMobile) + { + Cursor.lockState = CursorLockMode.None; + Cursor.visible = true; + } + } + + public void OnMove(InputValue value) + { + moveInput = value.Get(); + } + + public void OnLook(InputValue value) + { + lookInput = value.Get(); + } + + public void OnSprint(InputValue value) + { + sprintPressed = value.isPressed; + } + + public void OnJump(InputValue value) + { + if (value.isPressed) + { + jumpQueued = true; + } + } + + private void Update() + { + if (!IsOwner) + { + return; + } + + ApplyLook(); + ApplyMovement(); + } + + private void ApplyLook() + { + float sensitivity = isMobile ? touchLookSensitivity : mouseLookSensitivity; + Vector2 scaledLook = lookInput * sensitivity; + + pitch = Mathf.Clamp(pitch - scaledLook.y, minPitch, maxPitch); + + if (cameraPivot != null) + { + cameraPivot.localRotation = Quaternion.Euler(pitch, 0f, 0f); + } + + transform.Rotate(Vector3.up * scaledLook.x); + } + + private void ApplyMovement() + { + bool grounded = characterController.isGrounded; + if (grounded && verticalVelocity < 0f) + { + verticalVelocity = -2f; + } + + float speed = sprintPressed ? sprintSpeed : walkSpeed; + Vector3 planarMove = (transform.right * moveInput.x + transform.forward * moveInput.y) * speed; + characterController.Move(planarMove * Time.deltaTime); + + if (jumpQueued && grounded) + { + verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity); + } + + jumpQueued = false; + verticalVelocity += gravity * Time.deltaTime; + characterController.Move(Vector3.up * (verticalVelocity * Time.deltaTime)); + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Player/PlayerStats.cs b/Assets/HorrorCoopGame/Scripts/Player/PlayerStats.cs new file mode 100644 index 0000000..7d2c50a --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Player/PlayerStats.cs @@ -0,0 +1,99 @@ +using Unity.Netcode; +using UnityEngine; +using UnityEngine.UI; + +namespace HorrorCoopGame.Player +{ + public sealed class PlayerStats : NetworkBehaviour + { + public readonly NetworkVariable Health = new( + 100f, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + + public readonly NetworkVariable Stamina = new( + 100f, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + + public readonly NetworkVariable Sanity = new( + 100f, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + + [Header("Local UI")] + [SerializeField] private Image healthBarFill; + [SerializeField] private Image staminaBarFill; + [SerializeField] private Image sanityBarFill; + [SerializeField] private CanvasGroup sanityVignette; + + [Header("Server Tuning")] + [SerializeField] private float staminaRegenPerSecond = 5f; + + private void Update() + { + if (IsOwner) + { + UpdateLocalUi(); + } + + if (IsServer) + { + RegenerateServerStamina(); + } + } + + [ServerRpc(RequireOwnership = false)] + public void TakeDamageServerRpc(float amount) + { + float clampedAmount = Mathf.Max(0f, amount); + Health.Value = Mathf.Clamp(Health.Value - clampedAmount, 0f, 100f); + } + + [ServerRpc(RequireOwnership = false)] + public void ModifySanityServerRpc(float delta) + { + Sanity.Value = Mathf.Clamp(Sanity.Value + delta, 0f, 100f); + } + + [ServerRpc(RequireOwnership = false)] + public void UseStaminaServerRpc(float amount) + { + float clampedAmount = Mathf.Max(0f, amount); + Stamina.Value = Mathf.Clamp(Stamina.Value - clampedAmount, 0f, 100f); + } + + private void UpdateLocalUi() + { + if (healthBarFill != null) + { + healthBarFill.fillAmount = Health.Value / 100f; + } + + if (staminaBarFill != null) + { + staminaBarFill.fillAmount = Stamina.Value / 100f; + } + + if (sanityBarFill != null) + { + sanityBarFill.fillAmount = Sanity.Value / 100f; + } + + if (sanityVignette != null) + { + sanityVignette.alpha = 1f - (Sanity.Value / 100f); + } + } + + private void RegenerateServerStamina() + { + if (Stamina.Value >= 100f) + { + return; + } + + Stamina.Value = Mathf.Clamp(Stamina.Value + (staminaRegenPerSecond * Time.deltaTime), 0f, 100f); + } + } +} diff --git a/Assets/HorrorCoopGame/TouchControls/.gitkeep b/Assets/HorrorCoopGame/TouchControls/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Assets/HorrorCoopGame/UI/.gitkeep b/Assets/HorrorCoopGame/UI/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index eef1504..361ed2c 100644 --- a/README.md +++ b/README.md @@ -1 +1,50 @@ -# Newhorror \ No newline at end of file +# Newhorror + +## Prototype Scope Implemented +This repository now includes **Phase 1** and **Phase 2** foundations for a Unity co-op survival horror prototype targeting WebGL + Mobile. + +## Folder Structure +```text +Assets/ +└── HorrorCoopGame/ + ├── Scenes/ + ├── Scripts/ + │ ├── Networking/ + │ ├── Player/ + │ ├── Interaction/ + │ ├── Building/ + │ └── AI/ + ├── Prefabs/ + ├── ScriptableObjects/ + ├── UI/ + └── TouchControls/ +``` + +## Included Scripts +- `Assets/HorrorCoopGame/Scripts/Networking/NetworkWebSocketSetup.cs` +- `Assets/HorrorCoopGame/Scripts/Networking/RelayManager.cs` +- `Assets/HorrorCoopGame/Scripts/Networking/NetworkMenuUI.cs` +- `Assets/HorrorCoopGame/Scripts/Player/PlayerController.cs` +- `Assets/HorrorCoopGame/Scripts/Player/PlayerStats.cs` + +## On-Screen Touch Setup (Phase 2) +1. Install packages: + - **Input System** + - **Netcode for GameObjects** + - **Relay** + **Lobby** (services) + - **Input System On-Screen Controls** +2. Create `InputActions` with actions: `Move (Vector2)`, `Look (Vector2)`, `Sprint`, `Jump`, `Interact`, `BuildMode`. +3. Add `PlayerInput` to the player prefab and set behavior to **Invoke Unity Events** or **Send Messages** matching methods in `PlayerController`. +4. Create a `TouchControlsCanvas` (Canvas Scaler: **Scale With Screen Size**, Reference Resolution **1920x1080**). +5. Add controls under `TouchControlsCanvas`: + - Left: `On-Screen Stick` bound to `Move` + - Right: drag zone (`OnScreenStick` or custom delta binding) bound to `Look` + - Buttons: `On-Screen Button` for `Jump`, `Interact`, `BuildMode`, and optional `Sprint` +6. Assign the `TouchControlsCanvas` reference on `PlayerController`. +7. At runtime, `PlayerController` auto-enables touch UI only on mobile (`Application.isMobilePlatform`) and keeps desktop/WebGL keyboard+mouse flow active. + +## Networking Setup Notes (Phase 1) +1. Create a `NetworkManager` prefab with `UnityTransport`. +2. Add `NetworkWebSocketSetup` on the same object to force `UnityTransport.UseWebSockets = true` (required for WebGL browser clients). +3. Add `RelayManager` to a bootstrap scene object and wire `NetworkManager` reference. +4. Build menu UI and attach `NetworkMenuUI` with Host/Join buttons, join code input, and status label. From a2f9ff05070d2bba016cc34c0b02c5adcac5e541 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 01:36:16 +0000 Subject: [PATCH 3/3] Implement Phases 3-7: inventory, building, vehicle escape, AI, sanity, day/night, flashlight, responsive UI Agent-Logs-Url: https://github.com/t7451/Newhorror/sessions/36311db3-dd64-4203-9fdb-20e3f8d151d5 Co-authored-by: ksksrbiz-arch <240277128+ksksrbiz-arch@users.noreply.github.com> --- Assets/HorrorCoopGame/Scripts/AI/.gitkeep | 0 Assets/HorrorCoopGame/Scripts/AI/EnemyAI.cs | 192 +++++++++++++++++ .../HorrorCoopGame/Scripts/AI/SanityDrain.cs | 106 ++++++++++ .../HorrorCoopGame/Scripts/Building/.gitkeep | 0 .../Scripts/Building/BuildableData.cs | 16 ++ .../Scripts/Building/BuildingManager.cs | 193 ++++++++++++++++++ .../Scripts/Building/StructureHealth.cs | 46 +++++ .../Scripts/Environment/DayNightCycle.cs | 60 ++++++ .../Environment/PerformantFlashlight.cs | 72 +++++++ .../Scripts/Interaction/.gitkeep | 0 .../Scripts/Interaction/IInteractable.cs | 8 + .../Scripts/Interaction/InventoryGridUI.cs | 137 +++++++++++++ .../Scripts/Interaction/InventorySystem.cs | 147 +++++++++++++ .../Scripts/Interaction/ItemData.cs | 13 ++ .../Interaction/NetworkedPoolManager.cs | 145 +++++++++++++ .../Interaction/PlayerInteractionRaycast.cs | 115 +++++++++++ .../Scripts/Interaction/ScrapPile.cs | 61 ++++++ .../Scripts/UI/ResponsiveCanvasScaler.cs | 49 +++++ .../Scripts/Vehicle/VehicleRepair.cs | 131 ++++++++++++ README.md | 29 +++ 20 files changed, 1520 insertions(+) delete mode 100644 Assets/HorrorCoopGame/Scripts/AI/.gitkeep create mode 100644 Assets/HorrorCoopGame/Scripts/AI/EnemyAI.cs create mode 100644 Assets/HorrorCoopGame/Scripts/AI/SanityDrain.cs delete mode 100644 Assets/HorrorCoopGame/Scripts/Building/.gitkeep create mode 100644 Assets/HorrorCoopGame/Scripts/Building/BuildableData.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Building/BuildingManager.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Building/StructureHealth.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Environment/DayNightCycle.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Environment/PerformantFlashlight.cs delete mode 100644 Assets/HorrorCoopGame/Scripts/Interaction/.gitkeep create mode 100644 Assets/HorrorCoopGame/Scripts/Interaction/IInteractable.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Interaction/InventoryGridUI.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Interaction/InventorySystem.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Interaction/ItemData.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Interaction/NetworkedPoolManager.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Interaction/PlayerInteractionRaycast.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Interaction/ScrapPile.cs create mode 100644 Assets/HorrorCoopGame/Scripts/UI/ResponsiveCanvasScaler.cs create mode 100644 Assets/HorrorCoopGame/Scripts/Vehicle/VehicleRepair.cs diff --git a/Assets/HorrorCoopGame/Scripts/AI/.gitkeep b/Assets/HorrorCoopGame/Scripts/AI/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Assets/HorrorCoopGame/Scripts/AI/EnemyAI.cs b/Assets/HorrorCoopGame/Scripts/AI/EnemyAI.cs new file mode 100644 index 0000000..b56f461 --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/AI/EnemyAI.cs @@ -0,0 +1,192 @@ +using HorrorCoopGame.Player; +using Unity.Netcode; +using UnityEngine; +using UnityEngine.AI; + +namespace HorrorCoopGame.AI +{ + /// + /// Server-only enemy AI driven by a simple state machine. Pathfinding + /// destination updates are throttled to keep WebGL/mobile CPU low. + /// + [RequireComponent(typeof(NavMeshAgent))] + public sealed class EnemyAI : NetworkBehaviour + { + private enum State + { + Patrol, + Chase, + Attack + } + + [Header("Behaviour")] + [SerializeField] private float detectionRadius = 12f; + [SerializeField] private float loseSightRadius = 18f; + [SerializeField] private float attackRange = 1.8f; + [SerializeField] private float attackCooldown = 1.25f; + [SerializeField] private float attackDamage = 12f; + [SerializeField] private float patrolRadius = 8f; + + [Header("Optimization")] + [SerializeField] private float destinationUpdateInterval = 0.2f; + + private NavMeshAgent agent; + private State state = State.Patrol; + private float nextPathUpdateTime; + private float nextAttackTime; + private Vector3 patrolAnchor; + private Vector3 patrolTarget; + private Transform chaseTarget; + + public override void OnNetworkSpawn() + { + if (!IsServer) + { + // Pathfinding is server-authoritative only. + if (TryGetComponent(out NavMeshAgent localAgent)) + { + localAgent.enabled = false; + } + + enabled = false; + return; + } + + agent = GetComponent(); + patrolAnchor = transform.position; + ChoosePatrolTarget(); + } + + private void Update() + { + if (Time.time < nextPathUpdateTime) + { + return; + } + + nextPathUpdateTime = Time.time + destinationUpdateInterval; + + switch (state) + { + case State.Patrol: + TickPatrol(); + break; + case State.Chase: + TickChase(); + break; + case State.Attack: + TickAttack(); + break; + } + } + + private void TickPatrol() + { + if (TryFindClosestPlayer(detectionRadius, out Transform player)) + { + chaseTarget = player; + state = State.Chase; + return; + } + + if (!agent.pathPending && agent.remainingDistance <= agent.stoppingDistance + 0.1f) + { + ChoosePatrolTarget(); + } + + agent.SetDestination(patrolTarget); + } + + private void TickChase() + { + if (chaseTarget == null) + { + state = State.Patrol; + return; + } + + float distance = Vector3.Distance(transform.position, chaseTarget.position); + if (distance > loseSightRadius) + { + chaseTarget = null; + state = State.Patrol; + return; + } + + if (distance <= attackRange) + { + state = State.Attack; + return; + } + + agent.SetDestination(chaseTarget.position); + } + + private void TickAttack() + { + if (chaseTarget == null) + { + state = State.Patrol; + return; + } + + float distance = Vector3.Distance(transform.position, chaseTarget.position); + if (distance > attackRange + 0.5f) + { + state = State.Chase; + return; + } + + agent.SetDestination(transform.position); + + if (Time.time < nextAttackTime) + { + return; + } + + nextAttackTime = Time.time + attackCooldown; + if (chaseTarget.TryGetComponent(out PlayerStats stats)) + { + stats.TakeDamageServerRpc(attackDamage); + } + } + + private bool TryFindClosestPlayer(float radius, out Transform closest) + { + closest = null; + float bestSqr = radius * radius; + + foreach (var kvp in NetworkManager.Singleton.ConnectedClients) + { + NetworkObject playerObject = kvp.Value.PlayerObject; + if (playerObject == null) + { + continue; + } + + float sqr = (playerObject.transform.position - transform.position).sqrMagnitude; + if (sqr <= bestSqr) + { + bestSqr = sqr; + closest = playerObject.transform; + } + } + + return closest != null; + } + + private void ChoosePatrolTarget() + { + Vector2 random = Random.insideUnitCircle * patrolRadius; + Vector3 candidate = patrolAnchor + new Vector3(random.x, 0f, random.y); + if (NavMesh.SamplePosition(candidate, out NavMeshHit hit, patrolRadius, NavMesh.AllAreas)) + { + patrolTarget = hit.position; + } + else + { + patrolTarget = patrolAnchor; + } + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/AI/SanityDrain.cs b/Assets/HorrorCoopGame/Scripts/AI/SanityDrain.cs new file mode 100644 index 0000000..5831261 --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/AI/SanityDrain.cs @@ -0,0 +1,106 @@ +using HorrorCoopGame.Player; +using Unity.Netcode; +using UnityEngine; + +namespace HorrorCoopGame.AI +{ + /// + /// Drains the player's Sanity NetworkVariable while in darkness. + /// Uses light probes / sampling at a low cadence to avoid hot-loop costs. + /// Audio hallucinations are played client-side via a ClientRpc. + /// + [RequireComponent(typeof(PlayerStats))] + public sealed class SanityDrain : NetworkBehaviour + { + [SerializeField] private float drainPerSecond = 1.5f; + [SerializeField] private float regenPerSecond = 0.75f; + [SerializeField] private float darknessThreshold = 0.25f; + [SerializeField] private float evaluationInterval = 0.5f; + [SerializeField] private AudioClip[] hallucinationClips; + [SerializeField] private AudioSource hallucinationSource; + [SerializeField] private float hallucinationMinInterval = 8f; + [SerializeField] private float hallucinationMaxInterval = 18f; + + private PlayerStats stats; + private float nextEvaluationTime; + private float nextHallucinationTime; + + public override void OnNetworkSpawn() + { + stats = GetComponent(); + + if (!IsServer && !IsOwner) + { + enabled = false; + } + + ScheduleNextHallucination(); + } + + private void Update() + { + if (IsServer && Time.time >= nextEvaluationTime) + { + nextEvaluationTime = Time.time + evaluationInterval; + EvaluateSanityServer(); + } + + if (IsOwner) + { + TryPlayHallucination(); + } + } + + private void EvaluateSanityServer() + { + float light = SampleAmbientLight(); + float delta = light < darknessThreshold + ? -drainPerSecond * evaluationInterval + : regenPerSecond * evaluationInterval; + + stats.Sanity.Value = Mathf.Clamp(stats.Sanity.Value + delta, 0f, 100f); + } + + private float SampleAmbientLight() + { + // Cheap, allocation-free probe of baked ambient + sky light intensity. + Color ambient = RenderSettings.ambientLight; + float intensity = (ambient.r + ambient.g + ambient.b) / 3f; + intensity = Mathf.Max(intensity, RenderSettings.ambientIntensity); + + if (RenderSettings.sun != null) + { + intensity += RenderSettings.sun.intensity * Mathf.Clamp01(Vector3.Dot(Vector3.up, -RenderSettings.sun.transform.forward)); + } + + return Mathf.Clamp01(intensity); + } + + private void TryPlayHallucination() + { + if (Time.time < nextHallucinationTime || hallucinationSource == null || hallucinationClips == null || hallucinationClips.Length == 0) + { + return; + } + + if (stats.Sanity.Value > 60f) + { + ScheduleNextHallucination(); + return; + } + + AudioClip clip = hallucinationClips[Random.Range(0, hallucinationClips.Length)]; + if (clip != null) + { + hallucinationSource.PlayOneShot(clip); + } + + ScheduleNextHallucination(); + } + + private void ScheduleNextHallucination() + { + nextHallucinationTime = Time.time + Random.Range(hallucinationMinInterval, hallucinationMaxInterval); + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Building/.gitkeep b/Assets/HorrorCoopGame/Scripts/Building/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Assets/HorrorCoopGame/Scripts/Building/BuildableData.cs b/Assets/HorrorCoopGame/Scripts/Building/BuildableData.cs new file mode 100644 index 0000000..57df79c --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Building/BuildableData.cs @@ -0,0 +1,16 @@ +using UnityEngine; + +namespace HorrorCoopGame.Building +{ + [CreateAssetMenu(fileName = "NewBuildableData", menuName = "Survival Horror/Buildable Data")] + public sealed class BuildableData : ScriptableObject + { + public string buildableName; + public GameObject prefab; + public GameObject ghostPrefab; + public string requiredItemName = "ScrapMetal"; + public int requiredItemAmount = 4; + public float gridSize = 1f; + [Range(0.05f, 5f)] public float yawSnap = 90f; + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Building/BuildingManager.cs b/Assets/HorrorCoopGame/Scripts/Building/BuildingManager.cs new file mode 100644 index 0000000..5db92bd --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Building/BuildingManager.cs @@ -0,0 +1,193 @@ +using HorrorCoopGame.Interaction; +using Unity.Netcode; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace HorrorCoopGame.Building +{ + /// + /// Owner-only build mode controller with snap-to-grid ghost placement. + /// Confirmation fires a ServerRpc which pulls a pooled NetworkObject. + /// + public sealed class BuildingManager : NetworkBehaviour + { + [SerializeField] private Transform cameraTransform; + [SerializeField] private BuildableData currentBuildable; + [SerializeField] private float placementDistance = 4f; + [SerializeField] private LayerMask groundMask = ~0; + [SerializeField] private InventorySystem inventory; + + private GameObject ghostInstance; + private bool isBuildMode; + private float ghostYaw; + + public override void OnNetworkSpawn() + { + if (!IsOwner) + { + enabled = false; + } + } + + public override void OnNetworkDespawn() + { + DestroyGhost(); + } + + public void OnBuildMode(InputValue value) + { + if (!value.isPressed) + { + return; + } + + isBuildMode = !isBuildMode; + if (isBuildMode) + { + CreateGhost(); + } + else + { + DestroyGhost(); + } + } + + /// + /// Invoked by the on-screen "Confirm Build" button or keyboard binding. + /// + public void OnConfirmBuild(InputValue value) + { + if (!value.isPressed || !isBuildMode || currentBuildable == null) + { + return; + } + + if (!TryGetPlacementPose(out Vector3 position, out Quaternion rotation)) + { + return; + } + + RequestPlaceServerRpc(position, rotation); + } + + private void Update() + { + if (!isBuildMode || ghostInstance == null || currentBuildable == null) + { + return; + } + + if (!TryGetPlacementPose(out Vector3 position, out Quaternion rotation)) + { + ghostInstance.SetActive(false); + return; + } + + ghostInstance.SetActive(true); + ghostInstance.transform.SetPositionAndRotation(position, rotation); + } + + public void RotateGhost(float deltaDegrees) + { + if (currentBuildable == null) + { + return; + } + + ghostYaw += deltaDegrees; + ghostYaw = Mathf.Round(ghostYaw / currentBuildable.yawSnap) * currentBuildable.yawSnap; + } + + private bool TryGetPlacementPose(out Vector3 position, out Quaternion rotation) + { + position = default; + rotation = Quaternion.identity; + + if (cameraTransform == null || currentBuildable == null) + { + return false; + } + + Vector3 origin = cameraTransform.position; + Vector3 forward = cameraTransform.forward; + + Vector3 rawPoint; + if (Physics.Raycast(origin, forward, out RaycastHit hit, placementDistance, groundMask, QueryTriggerInteraction.Ignore)) + { + rawPoint = hit.point; + } + else + { + rawPoint = origin + (forward * placementDistance); + } + + float grid = Mathf.Max(0.05f, currentBuildable.gridSize); + position = new Vector3( + Mathf.Round(rawPoint.x / grid) * grid, + Mathf.Round(rawPoint.y / grid) * grid, + Mathf.Round(rawPoint.z / grid) * grid); + + rotation = Quaternion.Euler(0f, ghostYaw, 0f); + return true; + } + + private void CreateGhost() + { + if (currentBuildable == null || currentBuildable.ghostPrefab == null) + { + return; + } + + DestroyGhost(); + ghostInstance = Instantiate(currentBuildable.ghostPrefab); + ghostInstance.SetActive(false); + } + + private void DestroyGhost() + { + if (ghostInstance != null) + { + Destroy(ghostInstance); + ghostInstance = null; + } + } + + [ServerRpc] + private void RequestPlaceServerRpc(Vector3 position, Quaternion rotation, ServerRpcParams rpcParams = default) + { + if (currentBuildable == null || currentBuildable.prefab == null) + { + return; + } + + ulong senderId = rpcParams.Receive.SenderClientId; + if (!NetworkManager.Singleton.ConnectedClients.TryGetValue(senderId, out var client) || + client.PlayerObject == null) + { + return; + } + + InventorySystem requesterInventory = client.PlayerObject.GetComponent(); + if (requesterInventory == null) + { + return; + } + + if (!requesterInventory.HasItemQuantity(currentBuildable.requiredItemName, currentBuildable.requiredItemAmount)) + { + return; + } + + NetworkObject spawned = NetworkedPoolManager.Instance != null + ? NetworkedPoolManager.Instance.SpawnFromPool(currentBuildable.prefab, position, rotation) + : null; + + if (spawned == null) + { + return; + } + + requesterInventory.RemoveItemQuantity(currentBuildable.requiredItemName, currentBuildable.requiredItemAmount); + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Building/StructureHealth.cs b/Assets/HorrorCoopGame/Scripts/Building/StructureHealth.cs new file mode 100644 index 0000000..5a24a2a --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Building/StructureHealth.cs @@ -0,0 +1,46 @@ +using HorrorCoopGame.Interaction; +using Unity.Netcode; +using UnityEngine; + +namespace HorrorCoopGame.Building +{ + public sealed class StructureHealth : NetworkBehaviour + { + [SerializeField] private float maxHealth = 100f; + + public NetworkVariable Health = new( + 100f, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + + public override void OnNetworkSpawn() + { + if (IsServer) + { + Health.Value = maxHealth; + } + } + + [ServerRpc(RequireOwnership = false)] + public void TakeDamageServerRpc(float amount) + { + if (!IsServer || amount <= 0f) + { + return; + } + + Health.Value = Mathf.Max(0f, Health.Value - amount); + if (Health.Value <= 0f) + { + if (NetworkedPoolManager.Instance != null) + { + NetworkedPoolManager.Instance.ReturnToPool(NetworkObject); + } + else + { + NetworkObject.Despawn(true); + } + } + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Environment/DayNightCycle.cs b/Assets/HorrorCoopGame/Scripts/Environment/DayNightCycle.cs new file mode 100644 index 0000000..d1b69df --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Environment/DayNightCycle.cs @@ -0,0 +1,60 @@ +using Unity.Netcode; +using UnityEngine; + +namespace HorrorCoopGame.Environment +{ + /// + /// Baked-lighting friendly day/night cycle: rotates a single directional + /// light and lerps ambient color. No realtime GI updates. + /// + public sealed class DayNightCycle : NetworkBehaviour + { + [SerializeField] private Light sunLight; + [SerializeField] private Gradient ambientColorOverDay; + [SerializeField] private Gradient sunColorOverDay; + [SerializeField] private float dayLengthSeconds = 600f; + [SerializeField, Range(0f, 1f)] private float startTimeOfDay = 0.25f; + + public NetworkVariable TimeOfDay = new( + 0.25f, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + + public override void OnNetworkSpawn() + { + if (IsServer) + { + TimeOfDay.Value = Mathf.Repeat(startTimeOfDay, 1f); + } + } + + private void Update() + { + if (IsServer && dayLengthSeconds > 0f) + { + TimeOfDay.Value = Mathf.Repeat(TimeOfDay.Value + (Time.deltaTime / dayLengthSeconds), 1f); + } + + ApplyLighting(TimeOfDay.Value); + } + + private void ApplyLighting(float normalizedTime) + { + if (sunLight != null) + { + float sunAngle = (normalizedTime * 360f) - 90f; + sunLight.transform.rotation = Quaternion.Euler(sunAngle, 170f, 0f); + + if (sunColorOverDay != null) + { + sunLight.color = sunColorOverDay.Evaluate(normalizedTime); + } + } + + if (ambientColorOverDay != null) + { + RenderSettings.ambientLight = ambientColorOverDay.Evaluate(normalizedTime); + } + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Environment/PerformantFlashlight.cs b/Assets/HorrorCoopGame/Scripts/Environment/PerformantFlashlight.cs new file mode 100644 index 0000000..e286e65 --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Environment/PerformantFlashlight.cs @@ -0,0 +1,72 @@ +using Unity.Netcode; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace HorrorCoopGame.Environment +{ + /// + /// Owner-controlled, networked flashlight. Uses a spotlight with + /// shadows explicitly disabled for mobile/WebGL performance. + /// + [RequireComponent(typeof(Light))] + public sealed class PerformantFlashlight : NetworkBehaviour + { + [SerializeField] private Light spotLight; + + public NetworkVariable IsOn = new( + false, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + + private void Reset() + { + spotLight = GetComponent(); + } + + public override void OnNetworkSpawn() + { + if (spotLight == null) + { + spotLight = GetComponent(); + } + + if (spotLight != null) + { + spotLight.shadows = LightShadows.None; + spotLight.type = LightType.Spot; + spotLight.enabled = IsOn.Value; + } + + IsOn.OnValueChanged += HandleStateChanged; + } + + public override void OnNetworkDespawn() + { + IsOn.OnValueChanged -= HandleStateChanged; + } + + public void OnToggleFlashlight(InputValue value) + { + if (!value.isPressed || !IsOwner) + { + return; + } + + ToggleServerRpc(); + } + + [ServerRpc] + private void ToggleServerRpc() + { + IsOn.Value = !IsOn.Value; + } + + private void HandleStateChanged(bool _, bool newValue) + { + if (spotLight != null) + { + spotLight.enabled = newValue; + } + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Interaction/.gitkeep b/Assets/HorrorCoopGame/Scripts/Interaction/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Assets/HorrorCoopGame/Scripts/Interaction/IInteractable.cs b/Assets/HorrorCoopGame/Scripts/Interaction/IInteractable.cs new file mode 100644 index 0000000..1063032 --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Interaction/IInteractable.cs @@ -0,0 +1,8 @@ +namespace HorrorCoopGame.Interaction +{ + public interface IInteractable + { + string GetInteractPrompt(); + void Interact(ulong playerNetworkId); + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Interaction/InventoryGridUI.cs b/Assets/HorrorCoopGame/Scripts/Interaction/InventoryGridUI.cs new file mode 100644 index 0000000..a12a17d --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Interaction/InventoryGridUI.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using TMPro; +using Unity.Netcode; +using UnityEngine; +using UnityEngine.UI; + +namespace HorrorCoopGame.Interaction +{ + /// + /// Lightweight scalable grid UI that mirrors the local player's + /// NetworkList-backed inventory. Avoids re-instantiating slots + /// when only data changes. + /// + public sealed class InventoryGridUI : MonoBehaviour + { + [SerializeField] private RectTransform slotsParent; + [SerializeField] private GameObject slotPrefab; + + private readonly List slotViews = new(); + private InventorySystem trackedInventory; + + private struct SlotView + { + public Image Icon; + public TextMeshProUGUI Quantity; + } + + private void OnEnable() + { + TryBindLocalInventory(); + } + + private void OnDisable() + { + UnbindInventory(); + } + + private void Update() + { + if (trackedInventory == null) + { + TryBindLocalInventory(); + } + } + + private void TryBindLocalInventory() + { + if (NetworkManager.Singleton == null || !NetworkManager.Singleton.IsClient) + { + return; + } + + NetworkObject playerObject = NetworkManager.Singleton.LocalClient?.PlayerObject; + if (playerObject == null) + { + return; + } + + if (!playerObject.TryGetComponent(out InventorySystem inventory)) + { + return; + } + + trackedInventory = inventory; + inventory.Slots.OnListChanged += OnSlotsChanged; + Rebuild(); + } + + private void UnbindInventory() + { + if (trackedInventory != null && trackedInventory.Slots != null) + { + trackedInventory.Slots.OnListChanged -= OnSlotsChanged; + } + + trackedInventory = null; + } + + private void OnSlotsChanged(NetworkListEvent _) + { + Rebuild(); + } + + private void Rebuild() + { + if (trackedInventory == null || slotsParent == null || slotPrefab == null) + { + return; + } + + EnsureSlotViews(trackedInventory.Slots.Count); + + for (int i = 0; i < trackedInventory.Slots.Count; i++) + { + InventorySystem.InventorySlot slot = trackedInventory.Slots[i]; + SlotView view = slotViews[i]; + + bool empty = slot.Quantity <= 0 || slot.ItemName.Length == 0; + + if (view.Quantity != null) + { + view.Quantity.text = empty ? string.Empty : slot.Quantity.ToString(); + } + + if (view.Icon != null) + { + view.Icon.enabled = !empty; + } + } + } + + private void EnsureSlotViews(int desiredCount) + { + while (slotViews.Count < desiredCount) + { + GameObject instance = Instantiate(slotPrefab, slotsParent); + SlotView view = new SlotView + { + Icon = instance.GetComponentInChildren(includeInactive: true), + Quantity = instance.GetComponentInChildren(includeInactive: true) + }; + slotViews.Add(view); + } + + while (slotViews.Count > desiredCount) + { + int lastIndex = slotViews.Count - 1; + SlotView view = slotViews[lastIndex]; + if (view.Icon != null) + { + Destroy(view.Icon.transform.parent.gameObject); + } + slotViews.RemoveAt(lastIndex); + } + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Interaction/InventorySystem.cs b/Assets/HorrorCoopGame/Scripts/Interaction/InventorySystem.cs new file mode 100644 index 0000000..634a6fb --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Interaction/InventorySystem.cs @@ -0,0 +1,147 @@ +using Unity.Collections; +using Unity.Netcode; +using UnityEngine; + +namespace HorrorCoopGame.Interaction +{ + public sealed class InventorySystem : NetworkBehaviour + { + public struct InventorySlot : INetworkSerializable, System.IEquatable + { + public FixedString32Bytes ItemName; + public int Quantity; + + public void NetworkSerialize(BufferSerializer serializer) where T : IReaderWriter + { + serializer.SerializeValue(ref ItemName); + serializer.SerializeValue(ref Quantity); + } + + public bool Equals(InventorySlot other) + { + return ItemName.Equals(other.ItemName) && Quantity == other.Quantity; + } + } + + [SerializeField] private int slotCount = 6; + + public NetworkList Slots; + + private void Awake() + { + Slots = new NetworkList(); + } + + public override void OnNetworkSpawn() + { + if (IsServer && Slots.Count == 0) + { + for (int i = 0; i < slotCount; i++) + { + Slots.Add(new InventorySlot { ItemName = default, Quantity = 0 }); + } + } + } + + public override void OnDestroy() + { + base.OnDestroy(); + Slots?.Dispose(); + } + + /// + /// Server-only. Returns true when the full amount was added. + /// + public bool AddItem(ItemData item, int amount) + { + if (!IsServer || item == null || amount <= 0) + { + return false; + } + + FixedString32Bytes itemName = new FixedString32Bytes(item.itemName); + + for (int i = 0; i < Slots.Count && amount > 0; i++) + { + InventorySlot slot = Slots[i]; + if (slot.ItemName.Equals(itemName) && slot.Quantity < item.maxStack) + { + int space = item.maxStack - slot.Quantity; + int toAdd = Mathf.Min(space, amount); + slot.Quantity += toAdd; + Slots[i] = slot; + amount -= toAdd; + } + } + + for (int i = 0; i < Slots.Count && amount > 0; i++) + { + InventorySlot slot = Slots[i]; + if (slot.Quantity == 0) + { + int toAdd = Mathf.Min(item.maxStack, amount); + Slots[i] = new InventorySlot { ItemName = itemName, Quantity = toAdd }; + amount -= toAdd; + } + } + + return amount <= 0; + } + + public bool HasItemQuantity(string itemName, int requiredAmount) + { + FixedString32Bytes target = new FixedString32Bytes(itemName); + int total = 0; + foreach (InventorySlot slot in Slots) + { + if (slot.ItemName.Equals(target)) + { + total += slot.Quantity; + if (total >= requiredAmount) + { + return true; + } + } + } + + return false; + } + + /// + /// Server-only. Removes up to items. + /// Returns true if the full requested amount was removed. + /// + public bool RemoveItemQuantity(string itemName, int amountToRemove) + { + if (!IsServer || amountToRemove <= 0) + { + return false; + } + + FixedString32Bytes target = new FixedString32Bytes(itemName); + + for (int i = Slots.Count - 1; i >= 0 && amountToRemove > 0; i--) + { + InventorySlot slot = Slots[i]; + if (!slot.ItemName.Equals(target)) + { + continue; + } + + if (slot.Quantity > amountToRemove) + { + slot.Quantity -= amountToRemove; + Slots[i] = slot; + amountToRemove = 0; + } + else + { + amountToRemove -= slot.Quantity; + Slots[i] = new InventorySlot { ItemName = default, Quantity = 0 }; + } + } + + return amountToRemove <= 0; + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Interaction/ItemData.cs b/Assets/HorrorCoopGame/Scripts/Interaction/ItemData.cs new file mode 100644 index 0000000..c50cd43 --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Interaction/ItemData.cs @@ -0,0 +1,13 @@ +using UnityEngine; + +namespace HorrorCoopGame.Interaction +{ + [CreateAssetMenu(fileName = "NewItemData", menuName = "Survival Horror/Item Data")] + public sealed class ItemData : ScriptableObject + { + public string itemName; + public Sprite icon; + public int maxStack = 10; + public bool isKeyEngineComponent; + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Interaction/NetworkedPoolManager.cs b/Assets/HorrorCoopGame/Scripts/Interaction/NetworkedPoolManager.cs new file mode 100644 index 0000000..b245667 --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Interaction/NetworkedPoolManager.cs @@ -0,0 +1,145 @@ +using System.Collections.Generic; +using Unity.Netcode; +using UnityEngine; + +namespace HorrorCoopGame.Interaction +{ + /// + /// Server-authoritative object pool for NetworkObjects. Avoids + /// Instantiate/Destroy churn during play sessions on WebGL/mobile. + /// + public sealed class NetworkedPoolManager : NetworkBehaviour + { + public static NetworkedPoolManager Instance { get; private set; } + + [System.Serializable] + public struct PoolConfig + { + public GameObject prefab; + public int initialSize; + } + + [SerializeField] private List poolConfigurations = new(); + + private readonly Dictionary> pooledObjects = new(); + private readonly Dictionary prefabLookup = new(); + private readonly Dictionary spawnedToPrefabId = new(); + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(gameObject); + return; + } + + Instance = this; + } + + public override void OnNetworkSpawn() + { + if (!IsServer) + { + return; + } + + foreach (PoolConfig config in poolConfigurations) + { + if (config.prefab == null) + { + continue; + } + + int prefabId = GetPrefabId(config.prefab); + prefabLookup[prefabId] = config.prefab; + pooledObjects[prefabId] = new Queue(); + + for (int i = 0; i < config.initialSize; i++) + { + GameObject instance = Instantiate(config.prefab); + instance.SetActive(false); + NetworkObject networkObject = instance.GetComponent(); + if (networkObject == null) + { + Debug.LogError($"Pool prefab '{config.prefab.name}' is missing a NetworkObject."); + Destroy(instance); + continue; + } + + networkObject.Spawn(false); + pooledObjects[prefabId].Enqueue(networkObject); + } + } + } + + public NetworkObject SpawnFromPool(GameObject prefab, Vector3 position, Quaternion rotation) + { + if (!IsServer || prefab == null) + { + return null; + } + + int prefabId = GetPrefabId(prefab); + + if (!pooledObjects.TryGetValue(prefabId, out Queue queue)) + { + queue = new Queue(); + pooledObjects[prefabId] = queue; + prefabLookup[prefabId] = prefab; + } + + NetworkObject networkObject; + if (queue.Count > 0) + { + networkObject = queue.Dequeue(); + networkObject.transform.SetPositionAndRotation(position, rotation); + networkObject.gameObject.SetActive(true); + } + else + { + GameObject instance = Instantiate(prefab, position, rotation); + networkObject = instance.GetComponent(); + if (networkObject == null) + { + Debug.LogError($"Prefab '{prefab.name}' is missing a NetworkObject."); + Destroy(instance); + return null; + } + + networkObject.Spawn(true); + } + + spawnedToPrefabId[networkObject.NetworkObjectId] = prefabId; + return networkObject; + } + + public void ReturnToPool(NetworkObject networkObject) + { + if (!IsServer || networkObject == null) + { + return; + } + + if (!spawnedToPrefabId.TryGetValue(networkObject.NetworkObjectId, out int prefabId)) + { + networkObject.gameObject.SetActive(false); + return; + } + + networkObject.gameObject.SetActive(false); + + if (!pooledObjects.TryGetValue(prefabId, out Queue queue)) + { + queue = new Queue(); + pooledObjects[prefabId] = queue; + } + + queue.Enqueue(networkObject); + } + + private static int GetPrefabId(GameObject prefab) + { + return prefab.name.GetHashCode(); + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Interaction/PlayerInteractionRaycast.cs b/Assets/HorrorCoopGame/Scripts/Interaction/PlayerInteractionRaycast.cs new file mode 100644 index 0000000..f9ae550 --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Interaction/PlayerInteractionRaycast.cs @@ -0,0 +1,115 @@ +using TMPro; +using Unity.Netcode; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace HorrorCoopGame.Interaction +{ + /// + /// Owner-local interaction targeting. Uses an overlap sphere + /// at the camera forward point so mobile aiming is forgiving. + /// + public sealed class PlayerInteractionRaycast : NetworkBehaviour + { + [SerializeField] private Transform cameraTransform; + [SerializeField] private float interactRange = 3f; + [SerializeField] private float aimAssistRadius = 0.4f; + [SerializeField] private LayerMask interactableLayer = ~0; + [SerializeField] private TextMeshProUGUI promptText; + + private readonly Collider[] overlapBuffer = new Collider[8]; + private IInteractable currentTarget; + private bool interactPressed; + + public override void OnNetworkSpawn() + { + if (!IsOwner) + { + enabled = false; + } + } + + public void OnInteract(InputValue value) + { + if (value.isPressed) + { + interactPressed = true; + } + } + + private void Update() + { + if (!IsOwner) + { + return; + } + + UpdateTarget(); + UpdatePromptUi(); + + if (interactPressed) + { + interactPressed = false; + if (currentTarget != null) + { + currentTarget.Interact(NetworkManager.Singleton.LocalClientId); + } + } + } + + private void UpdateTarget() + { + currentTarget = null; + + if (cameraTransform == null) + { + return; + } + + Vector3 origin = cameraTransform.position; + Vector3 forward = cameraTransform.forward; + + // Prioritize a direct raycast hit + if (Physics.Raycast(origin, forward, out RaycastHit hit, interactRange, interactableLayer, QueryTriggerInteraction.Collide)) + { + if (hit.collider.TryGetComponent(out IInteractable directHit)) + { + currentTarget = directHit; + return; + } + } + + // Fallback: overlap sphere at the look point for mobile leniency + Vector3 sphereCenter = origin + (forward * interactRange); + int count = Physics.OverlapSphereNonAlloc(sphereCenter, aimAssistRadius, overlapBuffer, interactableLayer, QueryTriggerInteraction.Collide); + + float bestDot = -1f; + for (int i = 0; i < count; i++) + { + Collider col = overlapBuffer[i]; + if (col == null || !col.TryGetComponent(out IInteractable candidate)) + { + continue; + } + + Vector3 toCandidate = (col.transform.position - origin).normalized; + float dot = Vector3.Dot(forward, toCandidate); + if (dot > bestDot) + { + bestDot = dot; + currentTarget = candidate; + } + } + } + + private void UpdatePromptUi() + { + if (promptText == null) + { + return; + } + + promptText.text = currentTarget != null ? currentTarget.GetInteractPrompt() : string.Empty; + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Interaction/ScrapPile.cs b/Assets/HorrorCoopGame/Scripts/Interaction/ScrapPile.cs new file mode 100644 index 0000000..138633a --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Interaction/ScrapPile.cs @@ -0,0 +1,61 @@ +using Unity.Netcode; +using UnityEngine; + +namespace HorrorCoopGame.Interaction +{ + [RequireComponent(typeof(NetworkObject))] + public sealed class ScrapPile : NetworkBehaviour, IInteractable + { + [SerializeField] private ItemData resourceItem; + [SerializeField] private int yieldAmount = 3; + + public string GetInteractPrompt() + { + if (resourceItem == null) + { + return "Collect"; + } + + return $"Collect {resourceItem.itemName} (+{yieldAmount})"; + } + + public void Interact(ulong playerNetworkId) + { + RequestCollectServerRpc(playerNetworkId); + } + + [ServerRpc(RequireOwnership = false)] + private void RequestCollectServerRpc(ulong playerNetworkId) + { + if (resourceItem == null) + { + return; + } + + if (!NetworkManager.Singleton.ConnectedClients.TryGetValue(playerNetworkId, out var client) || + client.PlayerObject == null) + { + return; + } + + if (!client.PlayerObject.TryGetComponent(out InventorySystem inventory)) + { + return; + } + + if (!inventory.AddItem(resourceItem, yieldAmount)) + { + return; + } + + if (NetworkedPoolManager.Instance != null) + { + NetworkedPoolManager.Instance.ReturnToPool(NetworkObject); + } + else + { + gameObject.SetActive(false); + } + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/UI/ResponsiveCanvasScaler.cs b/Assets/HorrorCoopGame/Scripts/UI/ResponsiveCanvasScaler.cs new file mode 100644 index 0000000..1f23a91 --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/UI/ResponsiveCanvasScaler.cs @@ -0,0 +1,49 @@ +using UnityEngine; +using UnityEngine.UI; + +namespace HorrorCoopGame.UI +{ + /// + /// Forces a Canvas Scaler to "Scale With Screen Size" at 1920x1080 and + /// auto-selects width/height matching based on the current aspect ratio + /// so the UI is readable on phones (portrait/landscape) and WebGL. + /// + [RequireComponent(typeof(CanvasScaler))] + public sealed class ResponsiveCanvasScaler : MonoBehaviour + { + [SerializeField] private Vector2 referenceResolution = new(1920f, 1080f); + + private CanvasScaler scaler; + private int lastWidth; + private int lastHeight; + + private void Awake() + { + scaler = GetComponent(); + scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + scaler.referenceResolution = referenceResolution; + UpdateMatch(); + } + + private void Update() + { + if (Screen.width != lastWidth || Screen.height != lastHeight) + { + UpdateMatch(); + } + } + + private void UpdateMatch() + { + lastWidth = Screen.width; + lastHeight = Screen.height; + + float referenceAspect = referenceResolution.x / referenceResolution.y; + float screenAspect = lastHeight > 0 ? (float)lastWidth / lastHeight : referenceAspect; + + // Narrower than reference (e.g. portrait phones) -> match width. + // Wider than reference (e.g. ultra-wide) -> match height. + scaler.matchWidthOrHeight = screenAspect < referenceAspect ? 0f : 1f; + } + } +} diff --git a/Assets/HorrorCoopGame/Scripts/Vehicle/VehicleRepair.cs b/Assets/HorrorCoopGame/Scripts/Vehicle/VehicleRepair.cs new file mode 100644 index 0000000..6f16437 --- /dev/null +++ b/Assets/HorrorCoopGame/Scripts/Vehicle/VehicleRepair.cs @@ -0,0 +1,131 @@ +using HorrorCoopGame.Interaction; +using Unity.Netcode; +using UnityEngine; + +namespace HorrorCoopGame.Vehicle +{ + /// + /// Networked repair point. Requires three key components installed + /// (CarBattery, Alternator, SparkPlugs by default) to trigger escape. + /// + public sealed class VehicleRepair : NetworkBehaviour, IInteractable + { + [SerializeField] + private string[] requiredComponents = + { + "CarBattery", + "Alternator", + "SparkPlugs" + }; + + [SerializeField] private Transform escapeDriveTarget; + [SerializeField] private float escapeDriveDuration = 6f; + + public NetworkVariable InstalledCount = new( + 0, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + + public NetworkVariable IsRepaired = new( + false, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + + private readonly System.Collections.Generic.HashSet installedServer = new(); + + public string GetInteractPrompt() + { + if (IsRepaired.Value) + { + return "Escape!"; + } + + return $"Repair Vehicle ({InstalledCount.Value}/{requiredComponents.Length})"; + } + + public void Interact(ulong playerNetworkId) + { + RequestInstallServerRpc(playerNetworkId); + } + + [ServerRpc(RequireOwnership = false)] + private void RequestInstallServerRpc(ulong playerNetworkId) + { + if (IsRepaired.Value) + { + StartEscapeClientRpc(); + return; + } + + if (!NetworkManager.Singleton.ConnectedClients.TryGetValue(playerNetworkId, out var client) || + client.PlayerObject == null) + { + return; + } + + if (!client.PlayerObject.TryGetComponent(out InventorySystem inventory)) + { + return; + } + + for (int i = 0; i < requiredComponents.Length; i++) + { + string componentName = requiredComponents[i]; + if (installedServer.Contains(componentName)) + { + continue; + } + + if (!inventory.HasItemQuantity(componentName, 1)) + { + continue; + } + + if (!inventory.RemoveItemQuantity(componentName, 1)) + { + continue; + } + + installedServer.Add(componentName); + InstalledCount.Value = installedServer.Count; + + if (installedServer.Count >= requiredComponents.Length) + { + IsRepaired.Value = true; + StartEscapeClientRpc(); + } + + return; + } + } + + [ClientRpc] + private void StartEscapeClientRpc() + { + StartCoroutine(PlayEscapeSequence()); + } + + private System.Collections.IEnumerator PlayEscapeSequence() + { + if (escapeDriveTarget == null) + { + yield break; + } + + Vector3 start = transform.position; + Quaternion startRot = transform.rotation; + float elapsed = 0f; + + while (elapsed < escapeDriveDuration) + { + float t = elapsed / escapeDriveDuration; + transform.position = Vector3.Lerp(start, escapeDriveTarget.position, t); + transform.rotation = Quaternion.Slerp(startRot, escapeDriveTarget.rotation, t); + elapsed += Time.deltaTime; + yield return null; + } + + transform.SetPositionAndRotation(escapeDriveTarget.position, escapeDriveTarget.rotation); + } + } +} diff --git a/README.md b/README.md index 361ed2c..b7b1ab5 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,41 @@ Assets/ ``` ## Included Scripts +### Phase 1 — Networking - `Assets/HorrorCoopGame/Scripts/Networking/NetworkWebSocketSetup.cs` - `Assets/HorrorCoopGame/Scripts/Networking/RelayManager.cs` - `Assets/HorrorCoopGame/Scripts/Networking/NetworkMenuUI.cs` + +### Phase 2 — Player - `Assets/HorrorCoopGame/Scripts/Player/PlayerController.cs` - `Assets/HorrorCoopGame/Scripts/Player/PlayerStats.cs` +### Phase 3 — Inventory & Interaction (object pooled) +- `Assets/HorrorCoopGame/Scripts/Interaction/ItemData.cs` (ScriptableObject) +- `Assets/HorrorCoopGame/Scripts/Interaction/IInteractable.cs` +- `Assets/HorrorCoopGame/Scripts/Interaction/NetworkedPoolManager.cs` +- `Assets/HorrorCoopGame/Scripts/Interaction/ScrapPile.cs` +- `Assets/HorrorCoopGame/Scripts/Interaction/InventorySystem.cs` +- `Assets/HorrorCoopGame/Scripts/Interaction/PlayerInteractionRaycast.cs` +- `Assets/HorrorCoopGame/Scripts/Interaction/InventoryGridUI.cs` + +### Phase 4 — Scrap-metal Building +- `Assets/HorrorCoopGame/Scripts/Building/BuildableData.cs` (ScriptableObject) +- `Assets/HorrorCoopGame/Scripts/Building/BuildingManager.cs` (snap-to-grid ghost + ServerRpc place) +- `Assets/HorrorCoopGame/Scripts/Building/StructureHealth.cs` + +### Phase 5 — Escape Mechanic +- `Assets/HorrorCoopGame/Scripts/Vehicle/VehicleRepair.cs` (CarBattery + Alternator + SparkPlugs) + +### Phase 6 — AI & Sanity +- `Assets/HorrorCoopGame/Scripts/AI/EnemyAI.cs` (state machine + throttled NavMesh updates) +- `Assets/HorrorCoopGame/Scripts/AI/SanityDrain.cs` (darkness drain + audio hallucinations) + +### Phase 7 — Polish +- `Assets/HorrorCoopGame/Scripts/Environment/DayNightCycle.cs` (baked-lighting friendly) +- `Assets/HorrorCoopGame/Scripts/Environment/PerformantFlashlight.cs` (shadowless spotlight) +- `Assets/HorrorCoopGame/Scripts/UI/ResponsiveCanvasScaler.cs` (1920x1080 reference, adaptive match) + ## On-Screen Touch Setup (Phase 2) 1. Install packages: - **Input System**