Add practice range: shooting, AK47, intro cinematic, dressed warehouse scene
- PortalTestDestination practice range: Valorant-style hitscan rifle (recoil, spray/bloom, reload, controller support), debug HUD, practice-dummy targets with head/body hitboxes, hit/death/respawn animations, procedural SFX/FX - AK47 model (Quaternius, CC0) held via Animator IK, with fire/reload/equip anims - Intro cinematic (camera shots, title card, target beam-in, GO hand-off) - Scene dressing editor pass: CC0 textures, props, warehouse roof, lighting and post-processing; custom stylized skybox shader - CharacterMovement: camera recoil, shoulder offset, cinematic control lock - Unity CLI pipeline package; plus in-progress chat/Rive/player changes Asset credits: Assets/Models/Props/PROPS_CREDITS.txt (3 CC-BY 3.0 props), Assets/Textures/Range/TEXTURES_LICENSE.txt, Assets/Resources/*/..._LICENSE.txt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c674d76549
commit
5fc9ebf1e6
@@ -1,6 +1,7 @@
|
||||
using UnityEngine;
|
||||
using Mirror;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
|
||||
public class CharacterMovement : NetworkBehaviour
|
||||
{
|
||||
@@ -20,15 +21,55 @@ public class CharacterMovement : NetworkBehaviour
|
||||
bool lastSentWalking;
|
||||
bool lastSentRunning;
|
||||
|
||||
// assign this in the player prefab to an empty child transform positioned where the camera should sit (e.g. head)
|
||||
public Transform cameraMount;
|
||||
|
||||
// camera follow settings
|
||||
Camera mainCamera;
|
||||
Vector3 cameraLocalOffsetStable = new Vector3(0f, 2f, -4f); // fallback offset (local)
|
||||
public float followSpeed = 5f;
|
||||
public float idleFollowSpeed = 0.5f; // slower smoothing while idle (keeps camera stable)
|
||||
public Vector3 cameraLookOffset = new Vector3(0f, 1.5f, 0f); // where camera looks relative to player root
|
||||
|
||||
// Mouse-look orbit camera: yaw/pitch driven purely by the mouse, never by the player's
|
||||
// own transform.rotation, so turning/facing movement direction doesn't snap the camera.
|
||||
[Header("Camera Look")]
|
||||
public float cameraDistance = 4.5f;
|
||||
public float cameraPivotHeight = 1.6f;
|
||||
public float cameraPitchDefault = 15f;
|
||||
public float cameraPitchMin = -10f;
|
||||
public float cameraPitchMax = 65f;
|
||||
public float mouseSensitivity = 6f;
|
||||
// Degrees/sec at full stick deflection — a different scale than mouseSensitivity since
|
||||
// the right stick reports a steady -1..1 tilt rather than a per-frame pixel delta.
|
||||
public float gamepadLookSensitivity = 150f;
|
||||
|
||||
// Sideways offset of the orbit pivot (over-the-shoulder), so the character doesn't
|
||||
// block the center of the screen. Weapons set this; 0 keeps the classic centered orbit.
|
||||
[HideInInspector] public float cameraShoulderOffset = 0f;
|
||||
|
||||
// While true the character turns to face the camera's yaw (strafe-style, like a shooter)
|
||||
// instead of facing its movement direction.
|
||||
[HideInInspector] public bool faceCameraYaw;
|
||||
|
||||
[Header("Camera Recoil")]
|
||||
public float recoilKickSharpness = 35f;
|
||||
public float recoilRecoverDelay = 0.12f;
|
||||
public float recoilRecoverSharpness = 7f;
|
||||
|
||||
float cameraYaw;
|
||||
float cameraPitch;
|
||||
bool wantsCursorLocked = true;
|
||||
Vector2 currentLook;
|
||||
|
||||
// Recoil lives in its own offset on top of the mouse-driven yaw/pitch, so it can kick
|
||||
// and then recover back to where the player was aiming without fighting mouse input.
|
||||
Vector2 recoilTarget; // x = degrees up, y = degrees right
|
||||
Vector2 recoilCurrent;
|
||||
float lastRecoilTime = -10f;
|
||||
|
||||
public float CameraYaw => cameraYaw;
|
||||
|
||||
// Set by cutscenes (e.g. RangeIntroCinematic): freezes movement/look and hands the camera over.
|
||||
public static bool CinematicControl;
|
||||
|
||||
public void AddRecoil(float up, float right)
|
||||
{
|
||||
recoilTarget += new Vector2(up, right);
|
||||
lastRecoilTime = Time.time;
|
||||
}
|
||||
|
||||
void Awake()
|
||||
{
|
||||
@@ -47,6 +88,10 @@ public class CharacterMovement : NetworkBehaviour
|
||||
|
||||
input.CharacterControls.Run.performed += ctx => runPressed = ctx.ReadValueAsButton();
|
||||
input.CharacterControls.Run.canceled += ctx => runPressed = false;
|
||||
|
||||
// Right stick — camera look, same yaw/pitch the mouse drives (see LateUpdate).
|
||||
input.CharacterControls.Look.performed += ctx => currentLook = ctx.ReadValue<Vector2>();
|
||||
input.CharacterControls.Look.canceled += ctx => currentLook = Vector2.zero;
|
||||
}
|
||||
|
||||
void Start()
|
||||
@@ -61,45 +106,159 @@ public class CharacterMovement : NetworkBehaviour
|
||||
void Update()
|
||||
{
|
||||
if (!isLocalPlayer) return;
|
||||
if (CinematicControl)
|
||||
{
|
||||
ForceIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
HandleMovement();
|
||||
HandleRotation();
|
||||
}
|
||||
|
||||
// Camera follow should be in LateUpdate so it follows the final character pose for the frame
|
||||
// Camera follow/look should be in LateUpdate so it reacts to the final character pose
|
||||
// for the frame instead of lagging a frame behind Update's movement.
|
||||
void LateUpdate()
|
||||
{
|
||||
if (!isLocalPlayer) return;
|
||||
if (mainCamera == null) return;
|
||||
|
||||
// Desired world position based on the stable local offset (relative to player root)
|
||||
Vector3 desiredWorld = transform.TransformPoint(cameraLocalOffsetStable);
|
||||
|
||||
if (movementPressed)
|
||||
// A cinematic owns the camera: no look input, no camera writes, cursor stays hidden.
|
||||
if (CinematicControl)
|
||||
{
|
||||
// while moving: follow the player (smooth)
|
||||
mainCamera.transform.position = Vector3.Lerp(mainCamera.transform.position, desiredWorld, Time.deltaTime * followSpeed);
|
||||
}
|
||||
else
|
||||
{
|
||||
// while idle: keep camera stable behind the player (don't follow micro animation bobbing)
|
||||
// Update only XZ to remain behind, preserve camera's current Y (height) to avoid vertical bob
|
||||
Vector3 idleTarget = new Vector3(desiredWorld.x, mainCamera.transform.position.y, desiredWorld.z);
|
||||
mainCamera.transform.position = Vector3.Lerp(mainCamera.transform.position, idleTarget, Time.deltaTime * idleFollowSpeed);
|
||||
ApplyCursorLockState(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always look at the player (with an offset so we look near the torso/head)
|
||||
mainCamera.transform.LookAt(transform.position + cameraLookOffset);
|
||||
var kb = Keyboard.current;
|
||||
if (kb != null && kb.escapeKey.wasPressedThisFrame)
|
||||
{
|
||||
if (ChatUI.IsInputFieldFocused)
|
||||
{
|
||||
ChatUI.Instance?.ExitChatFocus();
|
||||
wantsCursorLocked = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
wantsCursorLocked = !wantsCursorLocked;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-assert the lock state every frame rather than trusting Cursor.lockState to have
|
||||
// stuck: the Editor Game view (and some platforms) silently drop the lock on focus
|
||||
// loss, so gating mouse-look on reading it back can permanently stall the camera.
|
||||
bool shouldLockNow = wantsCursorLocked && !ChatUI.IsInputFieldFocused;
|
||||
ApplyCursorLockState(shouldLockNow);
|
||||
|
||||
bool canLook = !ChatUI.IsInputFieldFocused && Application.isFocused;
|
||||
|
||||
if (canLook && shouldLockNow)
|
||||
{
|
||||
var mouse = Mouse.current;
|
||||
if (mouse != null)
|
||||
{
|
||||
Vector2 delta = mouse.delta.ReadValue();
|
||||
cameraYaw += delta.x * mouseSensitivity * Time.deltaTime;
|
||||
cameraPitch = Mathf.Clamp(cameraPitch - delta.y * mouseSensitivity * Time.deltaTime, cameraPitchMin, cameraPitchMax);
|
||||
|
||||
// CursorLockMode.Locked hides the cursor but doesn't reliably keep the OS
|
||||
// pointer confined/re-centered (especially inside the Editor Game view), so
|
||||
// without this the real cursor eventually reaches a screen edge and rotation
|
||||
// caps out in that direction instead of spinning freely. Warping back to
|
||||
// center every frame removes that ceiling — but WarpCursorPosition alone only
|
||||
// moves the OS cursor; the Input System's own tracked position doesn't know
|
||||
// about the jump and keeps computing future deltas as if the cursor were still
|
||||
// out near the edge, which is what was still capping rotation. InputState.Change
|
||||
// tells the Input System directly where the cursor now is, so the next delta
|
||||
// read is correct instead of walled off.
|
||||
Vector2 center = new Vector2(Screen.width * 0.5f, Screen.height * 0.5f);
|
||||
mouse.WarpCursorPosition(center);
|
||||
InputState.Change(mouse.position, center);
|
||||
}
|
||||
}
|
||||
|
||||
// Right stick look — independent of cursor lock (a gamepad doesn't have a cursor to
|
||||
// fight over), but still suppressed while typing in chat or the window is unfocused.
|
||||
if (canLook && currentLook.sqrMagnitude > 0.0001f)
|
||||
{
|
||||
cameraYaw += currentLook.x * gamepadLookSensitivity * Time.deltaTime;
|
||||
cameraPitch = Mathf.Clamp(cameraPitch - currentLook.y * gamepadLookSensitivity * Time.deltaTime, cameraPitchMin, cameraPitchMax);
|
||||
}
|
||||
|
||||
UpdateRecoil();
|
||||
UpdateCameraTransform();
|
||||
}
|
||||
|
||||
void UpdateRecoil()
|
||||
{
|
||||
if (Time.time - lastRecoilTime > recoilRecoverDelay)
|
||||
recoilTarget = Vector2.Lerp(recoilTarget, Vector2.zero, 1f - Mathf.Exp(-recoilRecoverSharpness * Time.deltaTime));
|
||||
|
||||
recoilCurrent = Vector2.Lerp(recoilCurrent, recoilTarget, 1f - Mathf.Exp(-recoilKickSharpness * Time.deltaTime));
|
||||
}
|
||||
|
||||
void ApplyCursorLockState(bool locked)
|
||||
{
|
||||
Cursor.lockState = locked ? CursorLockMode.Locked : CursorLockMode.None;
|
||||
Cursor.visible = !locked;
|
||||
}
|
||||
|
||||
// Orbits the camera around a pivot above the player at a fixed distance, using
|
||||
// cameraYaw/cameraPitch — never the player's own transform.rotation.
|
||||
void UpdateCameraTransform()
|
||||
{
|
||||
GetGameplayCameraPose(out Vector3 position, out Quaternion rotation);
|
||||
mainCamera.transform.SetPositionAndRotation(position, rotation);
|
||||
}
|
||||
|
||||
// Where the gameplay camera sits this frame — cinematics blend into this pose so the
|
||||
// hand-off back to the player is seamless.
|
||||
public void GetGameplayCameraPose(out Vector3 position, out Quaternion rotation)
|
||||
{
|
||||
rotation = Quaternion.Euler(cameraPitch - recoilCurrent.x, cameraYaw + recoilCurrent.y, 0f);
|
||||
Vector3 pivot = transform.position + Vector3.up * cameraPivotHeight;
|
||||
position = pivot + rotation * new Vector3(cameraShoulderOffset, 0f, -cameraDistance);
|
||||
}
|
||||
|
||||
// WASD input is camera-relative (like most third-person games): "forward" is whichever
|
||||
// way the camera currently faces, projected onto the ground, not a fixed world axis or
|
||||
// the player's own facing. Turning the camera therefore turns which way W walks you.
|
||||
Vector3 GetCameraRelativeMoveDirection()
|
||||
{
|
||||
Quaternion cameraYawRotation = Quaternion.Euler(0f, cameraYaw, 0f);
|
||||
return cameraYawRotation * new Vector3(combinedMovement.x, 0f, combinedMovement.y);
|
||||
}
|
||||
|
||||
// While a cinematic holds control, HandleMovement doesn't run to clear the walk/run flags —
|
||||
// and with root motion on, a stuck walk state would stroll the character across the map.
|
||||
void ForceIdle()
|
||||
{
|
||||
if (animator == null) return;
|
||||
animator.SetBool(isWalkingHash, false);
|
||||
animator.SetBool(isRunningHash, false);
|
||||
if (lastSentWalking || lastSentRunning)
|
||||
{
|
||||
lastSentWalking = false;
|
||||
lastSentRunning = false;
|
||||
CmdSendAnimationState(false, false);
|
||||
}
|
||||
}
|
||||
|
||||
void HandleRotation()
|
||||
{
|
||||
Vector3 currentPosition = transform.position;
|
||||
Vector3 newPosition = new Vector3(combinedMovement.x, 0, combinedMovement.y);
|
||||
// only rotate when there is movement to look at
|
||||
if (newPosition.sqrMagnitude > 0f)
|
||||
if (faceCameraYaw)
|
||||
{
|
||||
Vector3 positionToLookAt = currentPosition + newPosition;
|
||||
Quaternion aimYaw = Quaternion.Euler(0f, cameraYaw, 0f);
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation, aimYaw, 1f - Mathf.Exp(-20f * Time.deltaTime));
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 currentPosition = transform.position;
|
||||
Vector3 moveDirection = GetCameraRelativeMoveDirection();
|
||||
// only rotate when there is movement to look at
|
||||
if (moveDirection.sqrMagnitude > 0f)
|
||||
{
|
||||
Vector3 positionToLookAt = currentPosition + moveDirection;
|
||||
transform.LookAt(positionToLookAt);
|
||||
}
|
||||
}
|
||||
@@ -168,9 +327,9 @@ public class CharacterMovement : NetworkBehaviour
|
||||
CmdSendAnimationState(isWalking, isRunning);
|
||||
}
|
||||
|
||||
// movement translation (local)
|
||||
// movement translation, camera-relative (see GetCameraRelativeMoveDirection)
|
||||
float speed = isRunning ? 6f : 3f;
|
||||
Vector3 move = new Vector3(combinedMovement.x, 0, combinedMovement.y) * speed * Time.deltaTime;
|
||||
Vector3 move = GetCameraRelativeMoveDirection() * speed * Time.deltaTime;
|
||||
transform.position += move;
|
||||
}
|
||||
|
||||
@@ -179,7 +338,8 @@ public class CharacterMovement : NetworkBehaviour
|
||||
Debug.Log("Local player started: " + netId);
|
||||
input.CharacterControls.Enable();
|
||||
|
||||
// Setup camera for local player (do not parent to animated mount to avoid animation bobbing)
|
||||
// Setup camera for local player (unparented — position/rotation are driven every
|
||||
// frame from LateUpdate/UpdateCameraTransform, not by being a child of the player).
|
||||
mainCamera = Camera.main;
|
||||
if (mainCamera == null)
|
||||
{
|
||||
@@ -187,20 +347,14 @@ public class CharacterMovement : NetworkBehaviour
|
||||
return;
|
||||
}
|
||||
|
||||
// compute a stable local offset based on cameraMount, but use player root as the reference so animated child motion is ignored
|
||||
if (cameraMount != null)
|
||||
{
|
||||
cameraLocalOffsetStable = transform.InverseTransformPoint(cameraMount.position);
|
||||
}
|
||||
else
|
||||
{
|
||||
// fallback to a sensible offset if no mount assigned
|
||||
cameraLocalOffsetStable = new Vector3(0f, 2f, -4f);
|
||||
Debug.LogWarning("cameraMount not assigned on player prefab. Using fallback offset.");
|
||||
}
|
||||
|
||||
// unparent camera so we control world position directly
|
||||
// Start aligned behind the player's current facing; mouse look takes over from here.
|
||||
cameraYaw = transform.eulerAngles.y;
|
||||
cameraPitch = cameraPitchDefault;
|
||||
mainCamera.transform.SetParent(null, true);
|
||||
UpdateCameraTransform();
|
||||
|
||||
wantsCursorLocked = true;
|
||||
ApplyCursorLockState(wantsCursorLocked);
|
||||
|
||||
// ensure only the local player's camera has an active AudioListener
|
||||
var audio = mainCamera.GetComponent<AudioListener>();
|
||||
@@ -218,6 +372,9 @@ public class CharacterMovement : NetworkBehaviour
|
||||
if (audio != null) audio.enabled = false;
|
||||
}
|
||||
mainCamera = null;
|
||||
|
||||
wantsCursorLocked = false;
|
||||
ApplyCursorLockState(false);
|
||||
}
|
||||
|
||||
// Called on the server when client issues the command
|
||||
|
||||
@@ -37,22 +37,24 @@ public class ChatAuthenticator : NetworkAuthenticator
|
||||
|
||||
public override void OnStartServer()
|
||||
{
|
||||
Debug.Log("[ChatAuthenticator] Server authentication started.");
|
||||
NetworkServer.RegisterHandler<AuthRequestMessage>(OnAuthRequestMessage, false);
|
||||
}
|
||||
|
||||
public override void OnStopServer()
|
||||
{
|
||||
Debug.Log("[ChatAuthenticator] Server authentication stopped.");
|
||||
NetworkServer.UnregisterHandler<AuthRequestMessage>();
|
||||
}
|
||||
|
||||
public override void OnServerAuthenticate(NetworkConnectionToClient conn)
|
||||
{
|
||||
|
||||
Debug.LogWarning($"[ChatAuthenticator] OnServerAuthenticate called for connection {conn.connectionId}; waiting for AuthRequestMessage.");
|
||||
}
|
||||
|
||||
public void OnAuthRequestMessage(NetworkConnectionToClient conn, AuthRequestMessage msg)
|
||||
{
|
||||
Debug.Log($"Authentication Request Username: {msg.authUsername}");
|
||||
Debug.Log($"[ChatAuthenticator] Authentication request received from connection {conn.connectionId}. Username length: {msg.authUsername?.Length ?? 0}");
|
||||
|
||||
if (connectionsPendingDisconnect.Contains(conn)) return;
|
||||
|
||||
@@ -70,6 +72,7 @@ public class ChatAuthenticator : NetworkAuthenticator
|
||||
|
||||
conn.Send(authResponseMessage);
|
||||
|
||||
Debug.Log($"[ChatAuthenticator] Authentication accepted for connection {conn.connectionId}.");
|
||||
ServerAccept(conn);
|
||||
}
|
||||
else
|
||||
@@ -86,6 +89,7 @@ public class ChatAuthenticator : NetworkAuthenticator
|
||||
|
||||
conn.isAuthenticated = false;
|
||||
|
||||
Debug.LogWarning($"[ChatAuthenticator] Authentication rejected for connection {conn.connectionId}: username already in use.");
|
||||
StartCoroutine(DelayedDisconnect(conn, 1f));
|
||||
}
|
||||
}
|
||||
@@ -108,6 +112,7 @@ public class ChatAuthenticator : NetworkAuthenticator
|
||||
public void SetPlayerName(string username)
|
||||
{
|
||||
playerName = username;
|
||||
Debug.Log($"[ChatAuthenticator] Client username set. Length: {playerName?.Length ?? 0}");
|
||||
if (LoginUI.instance != null && LoginUI.instance.errorText != null)
|
||||
{
|
||||
LoginUI.instance.errorText.text = string.Empty;
|
||||
@@ -117,30 +122,33 @@ public class ChatAuthenticator : NetworkAuthenticator
|
||||
|
||||
public override void OnStartClient()
|
||||
{
|
||||
Debug.Log("[ChatAuthenticator] Client authentication started.");
|
||||
NetworkClient.RegisterHandler<AuthResponseMessage>(OnAuthResponseMessage, false);
|
||||
}
|
||||
|
||||
public override void OnStopClient()
|
||||
{
|
||||
Debug.Log("[ChatAuthenticator] Client authentication stopped.");
|
||||
NetworkClient.UnregisterHandler<AuthResponseMessage>();
|
||||
}
|
||||
|
||||
public override void OnClientAuthenticate()
|
||||
{
|
||||
Debug.Log($"[ChatAuthenticator] Sending authentication request. Username length: {playerName?.Length ?? 0}");
|
||||
NetworkClient.Send(new AuthRequestMessage { authUsername = playerName });
|
||||
}
|
||||
|
||||
public void OnAuthResponseMessage(AuthResponseMessage msg)
|
||||
{
|
||||
Debug.Log($"[ChatAuthenticator] Authentication response received. Code: {msg.code}, Message: '{msg.message}'");
|
||||
if (msg.code == 100)
|
||||
{
|
||||
Debug.Log($"Authentication Response: {msg.code} {msg.message}");
|
||||
|
||||
ClientAccept();
|
||||
Debug.Log("[ChatAuthenticator] Client accepted by server.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError($"Authentication Response: {msg.code} {msg.message}");
|
||||
Debug.LogError($"[ChatAuthenticator] Client authentication rejected. Code: {msg.code}, Message: '{msg.message}'");
|
||||
|
||||
NetworkManager.singleton.StopHost();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using UnityEngine;
|
||||
using Mirror;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
[AddComponentMenu("")]
|
||||
public class ChatNetworkManager : NetworkManager
|
||||
@@ -21,6 +22,8 @@ public class ChatNetworkManager : NetworkManager
|
||||
|
||||
public override void OnServerDisconnect(NetworkConnectionToClient conn)
|
||||
{
|
||||
Debug.Log($"[ChatNetworkManager] Server disconnecting connection {conn.connectionId}. Active scene: '{SceneManager.GetActiveScene().name}'");
|
||||
|
||||
if (conn.authenticationData != null)
|
||||
ChatAuthenticator.playerNames.Remove((string)conn.authenticationData);
|
||||
|
||||
@@ -31,6 +34,7 @@ public class ChatNetworkManager : NetworkManager
|
||||
|
||||
public override void OnClientDisconnect()
|
||||
{
|
||||
Debug.Log($"[ChatNetworkManager] Client disconnected. Active scene: '{SceneManager.GetActiveScene().name}'");
|
||||
base.OnClientDisconnect();
|
||||
|
||||
// Destroy ChatUI instance when disconnecting
|
||||
@@ -53,7 +57,7 @@ public class ChatNetworkManager : NetworkManager
|
||||
{
|
||||
base.OnClientConnect();
|
||||
|
||||
Debug.Log("ChatNetworkManager: OnClientConnect called");
|
||||
Debug.Log($"[ChatNetworkManager] OnClientConnect called. Active scene: '{SceneManager.GetActiveScene().name}'");
|
||||
|
||||
// Spawn ChatUI prefab locally when client successfully connects
|
||||
if (chatUIPrefab == null)
|
||||
@@ -89,6 +93,8 @@ public class ChatNetworkManager : NetworkManager
|
||||
|
||||
if (riveLoginCanvas != null)
|
||||
riveLoginCanvas.SetActive(false);
|
||||
|
||||
Debug.Log($"[ChatNetworkManager] Client connection setup completed. Active scene: '{SceneManager.GetActiveScene().name}'");
|
||||
}
|
||||
|
||||
[Server]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
@@ -35,6 +36,17 @@ public class ChatUI : MonoBehaviour
|
||||
// Expose whether the chat input is focused so other systems can disable input/movement
|
||||
public static bool IsInputFieldFocused => Instance != null && Instance.chatMessage != null && Instance.chatMessage.isFocused;
|
||||
|
||||
// Called by PlayerScript when Escape is pressed while chat is focused, so mouse-look/
|
||||
// cursor lock can resume immediately instead of waiting for a manual click-away.
|
||||
public void ExitChatFocus()
|
||||
{
|
||||
if (chatMessage != null)
|
||||
chatMessage.DeactivateInputField();
|
||||
|
||||
if (EventSystem.current != null)
|
||||
EventSystem.current.SetSelectedGameObject(null);
|
||||
}
|
||||
|
||||
// Called by Player.OnStartLocalPlayer to connect the UI to the player's network methods.
|
||||
public void Initialize(Player player)
|
||||
{
|
||||
|
||||
@@ -42,8 +42,11 @@ public class LoginUI : MonoBehaviour
|
||||
{
|
||||
var username = usernameInput.text.Trim();
|
||||
|
||||
Debug.Log($"[LoginUI] Client button pressed. Username length: {username.Length}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
Debug.LogWarning("[LoginUI] Client start blocked because the username is empty.");
|
||||
if (errorText != null) { errorText.text = "Enter a username"; errorText.gameObject.SetActive(true); }
|
||||
return;
|
||||
}
|
||||
@@ -51,11 +54,15 @@ public class LoginUI : MonoBehaviour
|
||||
// set the name on the ChatAuthenticator (if present)
|
||||
var auth = NetworkManager.singleton.GetComponent<ChatAuthenticator>();
|
||||
if (auth != null)
|
||||
{
|
||||
Debug.Log("[LoginUI] Assigning username to ChatAuthenticator.");
|
||||
auth.SetPlayerName(username);
|
||||
}
|
||||
else
|
||||
Debug.LogWarning("LoginUI: ChatAuthenticator not found on NetworkManager.");
|
||||
|
||||
// start client
|
||||
Debug.Log($"[LoginUI] Starting client. Network address: '{NetworkManager.singleton.networkAddress}'");
|
||||
NetworkManager.singleton.StartClient();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using UnityEngine.Rendering.Universal;
|
||||
|
||||
namespace ProceduralEditor
|
||||
{
|
||||
// "Set dressing" pass for the PortalTestDestination practice range, run after
|
||||
// SetupPortalTestDestinationScene (which builds the greybox):
|
||||
// - textures the greybox (pale concrete floor, cream plaster walls, concrete cover) with CC0 Poly Haven maps,
|
||||
// swapping every block's cube mesh for one whose UVs are in meters so textures never stretch;
|
||||
// - places CC0/CC-BY props (see Assets/Models/Props/PROPS_CREDITS.txt) with box colliders;
|
||||
// - raises the perimeter walls and builds a pitched warehouse roof (trusses, purlins, skylights);
|
||||
// - lights it like Valorant's range: warm afternoon sun, bright bounce ambient, pendant lamps;
|
||||
// - adds a global post-processing volume with a soft, warm, low-contrast grade.
|
||||
// Safe to re-run: it removes its own previous output first.
|
||||
public static class DressPortalTestDestinationScene
|
||||
{
|
||||
const string ScenePath = "Assets/Scenes/PortalTestDestination.unity";
|
||||
const string PropsFolder = "Assets/Models/Props";
|
||||
const string TexFolder = "Assets/Textures/Range";
|
||||
const string MeshFolder = "Assets/Models/Generated/RangeBlocks";
|
||||
const string MaterialsFolder = "Assets/Materials";
|
||||
const string ProfilePath = "Assets/Settings/RangePostProfile.asset";
|
||||
const string PropsRootName = "Props";
|
||||
const string LightingRootName = "RangeLighting";
|
||||
const string RoofRootName = "Roof";
|
||||
|
||||
[MenuItem("Tools/Scene/Dress Portal Test Destination (props, textures, lighting)")]
|
||||
public static void Dress()
|
||||
{
|
||||
if (EditorApplication.isPlaying)
|
||||
{
|
||||
Debug.LogError("Dress Portal Test Destination: exit Play Mode first.");
|
||||
return;
|
||||
}
|
||||
|
||||
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
|
||||
var map = GameObject.Find("ShooterMapPrototype");
|
||||
if (map == null)
|
||||
{
|
||||
Debug.LogError("Dress Portal Test Destination: 'ShooterMapPrototype' not found — run the setup menu first.");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var root in scene.GetRootGameObjects())
|
||||
if (root.name == PropsRootName || root.name == LightingRootName || root.name == RoofRootName)
|
||||
Object.DestroyImmediate(root);
|
||||
|
||||
TextureGreybox(map.transform);
|
||||
BuildRoof();
|
||||
PlaceProps();
|
||||
SetupLighting();
|
||||
|
||||
EditorSceneManager.MarkSceneDirty(scene);
|
||||
EditorSceneManager.SaveScene(scene);
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log("Portal Test Destination dressed and saved.");
|
||||
}
|
||||
|
||||
// ---- Greybox textures -----------------------------------------------------------
|
||||
|
||||
static Material floorMat, wallMat, boxMat, woodMat, deckMat, steelMat;
|
||||
|
||||
static void TextureGreybox(Transform map)
|
||||
{
|
||||
// Warehouse palette (matches Valorant's range): pale concrete, cream plaster, concrete cover.
|
||||
// Last args: metres per texture tile, tint, smoothness, normal strength.
|
||||
floorMat = SetupSurface("ShooterProto_Floor", "hangar_concrete_floor", 4f, new Color(1f, 0.97f, 0.92f), 0.3f, 0.8f);
|
||||
wallMat = SetupSurface("ShooterProto_Wall", "clay_plaster", 3f, new Color(1f, 0.93f, 0.82f), 0.1f, 0.9f);
|
||||
boxMat = SetupSurface("ShooterProto_Box", "brushed_concrete", 2f, new Color(0.9f, 0.88f, 0.86f), 0.3f, 0.8f);
|
||||
woodMat = SetupSurface("RangeWood", "rough_wood", 2f, new Color(0.95f, 0.8f, 0.62f), 0.15f, 1f);
|
||||
deckMat = SetupSurface("RangeRoofDeck", "wood_planks", 3f, new Color(0.85f, 0.72f, 0.58f), 0.1f, 1f);
|
||||
steelMat = SetupSurface("RangeSteel", "metal_plate", 2f, new Color(0.45f, 0.48f, 0.52f), 0.5f, 0.6f, metallic: 0.6f);
|
||||
|
||||
// Perimeter walls go up to the eaves now that there's a roof.
|
||||
foreach (var name in new[] { "Wall_North", "Wall_South", "Wall_East", "Wall_West" })
|
||||
{
|
||||
var w = map.Find(name);
|
||||
if (w == null) continue;
|
||||
w.localScale = new Vector3(w.localScale.x, EaveHeight, w.localScale.z);
|
||||
w.localPosition = new Vector3(w.localPosition.x, EaveHeight / 2f, w.localPosition.z);
|
||||
}
|
||||
|
||||
EnsureFolder(MeshFolder);
|
||||
foreach (var mf in map.GetComponentsInChildren<MeshFilter>())
|
||||
{
|
||||
var renderer = mf.GetComponent<MeshRenderer>();
|
||||
if (renderer.sharedMaterial == floorMat || renderer.sharedMaterial == wallMat || renderer.sharedMaterial == boxMat)
|
||||
mf.sharedMesh = GetMeterCube(mf.transform.lossyScale);
|
||||
}
|
||||
}
|
||||
|
||||
static Material SetupSurface(string materialName, string texName, float metersPerTile, Color tint, float smoothness, float normalStrength, float metallic = 0f)
|
||||
{
|
||||
string path = $"{MaterialsFolder}/{materialName}.mat";
|
||||
var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
|
||||
if (mat == null)
|
||||
{
|
||||
mat = new Material(Shader.Find("Universal Render Pipeline/Lit"));
|
||||
AssetDatabase.CreateAsset(mat, path);
|
||||
}
|
||||
|
||||
var albedo = AssetDatabase.LoadAssetAtPath<Texture2D>($"{TexFolder}/{texName}_diff_1k.jpg");
|
||||
var normalPath = $"{TexFolder}/{texName}_nor_gl_1k.jpg";
|
||||
var importer = (TextureImporter)AssetImporter.GetAtPath(normalPath);
|
||||
if (importer != null && importer.textureType != TextureImporterType.NormalMap)
|
||||
{
|
||||
importer.textureType = TextureImporterType.NormalMap;
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
var normal = AssetDatabase.LoadAssetAtPath<Texture2D>(normalPath);
|
||||
|
||||
// Mesh UVs are in meters (see GetMeterCube), so tiling = tiles per meter.
|
||||
var tiling = Vector2.one / metersPerTile;
|
||||
mat.SetTexture("_BaseMap", albedo);
|
||||
mat.SetTextureScale("_BaseMap", tiling);
|
||||
mat.SetColor("_BaseColor", tint);
|
||||
mat.SetTexture("_BumpMap", normal);
|
||||
mat.SetFloat("_BumpScale", normalStrength);
|
||||
mat.EnableKeyword("_NORMALMAP");
|
||||
mat.SetFloat("_Smoothness", smoothness);
|
||||
mat.SetFloat("_Metallic", metallic);
|
||||
EditorUtility.SetDirty(mat);
|
||||
return mat;
|
||||
}
|
||||
|
||||
static readonly Dictionary<string, Mesh> meshCache = new Dictionary<string, Mesh>();
|
||||
|
||||
// A unit cube whose UVs are scaled by the block's size, so 1 UV unit = 1 meter on every face.
|
||||
static Mesh GetMeterCube(Vector3 size)
|
||||
{
|
||||
size = new Vector3(Mathf.Abs(size.x), Mathf.Abs(size.y), Mathf.Abs(size.z));
|
||||
string key = $"Block_{size.x:0.##}x{size.y:0.##}x{size.z:0.##}".Replace(',', '.');
|
||||
if (meshCache.TryGetValue(key, out var cached) && cached != null) return cached;
|
||||
|
||||
string path = $"{MeshFolder}/{key}.asset";
|
||||
var mesh = AssetDatabase.LoadAssetAtPath<Mesh>(path);
|
||||
if (mesh == null)
|
||||
{
|
||||
mesh = BuildMeterCube(size);
|
||||
mesh.name = key;
|
||||
AssetDatabase.CreateAsset(mesh, path);
|
||||
}
|
||||
meshCache[key] = mesh;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
static Mesh BuildMeterCube(Vector3 s)
|
||||
{
|
||||
var verts = new List<Vector3>();
|
||||
var normals = new List<Vector3>();
|
||||
var uvs = new List<Vector2>();
|
||||
var tris = new List<int>();
|
||||
|
||||
// (normal, u axis, v axis, u size, v size)
|
||||
void Face(Vector3 n, Vector3 u, Vector3 v, float uSize, float vSize)
|
||||
{
|
||||
int start = verts.Count;
|
||||
Vector3 c = n * 0.5f;
|
||||
verts.Add(c - u * 0.5f - v * 0.5f); uvs.Add(new Vector2(0, 0));
|
||||
verts.Add(c + u * 0.5f - v * 0.5f); uvs.Add(new Vector2(uSize, 0));
|
||||
verts.Add(c + u * 0.5f + v * 0.5f); uvs.Add(new Vector2(uSize, vSize));
|
||||
verts.Add(c - u * 0.5f + v * 0.5f); uvs.Add(new Vector2(0, vSize));
|
||||
for (int i = 0; i < 4; i++) normals.Add(n);
|
||||
// Clockwise when viewed from outside (Unity's front face).
|
||||
tris.AddRange(new[] { start, start + 2, start + 1, start, start + 3, start + 2 });
|
||||
}
|
||||
|
||||
Face(Vector3.forward, Vector3.left, Vector3.up, s.x, s.y);
|
||||
Face(Vector3.back, Vector3.right, Vector3.up, s.x, s.y);
|
||||
Face(Vector3.right, Vector3.forward, Vector3.up, s.z, s.y);
|
||||
Face(Vector3.left, Vector3.back, Vector3.up, s.z, s.y);
|
||||
Face(Vector3.up, Vector3.right, Vector3.forward, s.x, s.z);
|
||||
Face(Vector3.down, Vector3.right, Vector3.back, s.x, s.z);
|
||||
|
||||
var mesh = new Mesh();
|
||||
mesh.SetVertices(verts);
|
||||
mesh.SetNormals(normals);
|
||||
mesh.SetUVs(0, uvs);
|
||||
mesh.SetTriangles(tris, 0);
|
||||
mesh.RecalculateTangents();
|
||||
mesh.RecalculateBounds();
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// ---- Props ----------------------------------------------------------------------
|
||||
|
||||
struct Placement
|
||||
{
|
||||
public string prop; public Vector3 pos; public float yaw;
|
||||
public Placement(string prop, float x, float y, float z, float yaw = 0f) { this.prop = prop; pos = new Vector3(x, y, z); this.yaw = yaw; }
|
||||
}
|
||||
|
||||
// Layout keeps the central street sightlines, the building doors, the targets and the intro
|
||||
// cinematic's dolly path (x -1.2..2.2, z 10..16) clear; props hug walls, corners and alleys.
|
||||
static readonly Placement[] Layout =
|
||||
{
|
||||
// Spawn cover
|
||||
new Placement("ConcreteBarrier", -3.8f, 0, -11.2f, 0),
|
||||
new Placement("ConcreteBarrier", 3.8f, 0, 11.2f, 180),
|
||||
new Placement("Sandbags", 3.6f, 0, -11.4f, 90),
|
||||
new Placement("Sandbags", -3.6f, 0, 11.4f, 90),
|
||||
|
||||
// Alley cover
|
||||
new Placement("ConcreteBarrier", 7.6f, 0, 3.2f, 90),
|
||||
new Placement("ConcreteBarrier", -7.6f, 0, -3.2f, 90),
|
||||
new Placement("Sandbags", -8.4f, 0, 4.8f, 0),
|
||||
new Placement("Sandbags", 8.4f, 0, -4.8f, 0),
|
||||
|
||||
// Street clutter
|
||||
new Placement("TrafficCone", 3.8f, 0, -4.4f, 0),
|
||||
new Placement("TrafficCone", 4.3f, 0, -3.6f, 25),
|
||||
new Placement("TrafficCone", -3.9f, 0, 5.2f, -10),
|
||||
new Placement("Pallet", 4.2f, 0, 5.8f, 12),
|
||||
|
||||
// South-west corner storage
|
||||
new Placement("Barrel", -20.6f, 0, -15.6f, 0),
|
||||
new Placement("Barrel", -19.7f, 0, -15.9f, 40),
|
||||
new Placement("Barrel", -20.4f, 0, -14.7f, 75),
|
||||
new Placement("Crate", -18.4f, 0, -15.8f, 5),
|
||||
new Placement("CubeCrate", -18.4f, 0.9f, -15.8f, 20),
|
||||
new Placement("Pallet", -16.6f, 0, -15.9f, 0),
|
||||
new Placement("Pallet", -16.6f, 0.14f, -15.9f, 6),
|
||||
|
||||
// North-east corner storage
|
||||
new Placement("Crate", 20.5f, 0, 15.6f, 0),
|
||||
new Placement("Crate", 19.5f, 0, 15.7f, 8),
|
||||
new Placement("Crate", 20.1f, 0.9f, 15.6f, -12),
|
||||
new Placement("Barrel", 17.9f, 0, 15.9f, 0),
|
||||
new Placement("Tires", 16.3f, 0, 15.7f, 0),
|
||||
|
||||
// Back alleys
|
||||
new Placement("Tires", -20.8f, 0, 4.5f, 90),
|
||||
new Placement("Barrel", -21.1f, 0, -5.4f, 0),
|
||||
new Placement("CubeCrate", -21.0f, 0, -6.4f, 15),
|
||||
new Placement("Tires", 20.8f, 0, -4.5f, 90),
|
||||
new Placement("Barrel", 21.1f, 0, 5.4f, 0),
|
||||
new Placement("Crate", 20.9f, 0, 6.5f, -8),
|
||||
new Placement("Pallet", 20.2f, 0, -12.6f, 90),
|
||||
new Placement("Pallet", -20.2f, 0, 12.6f, 90),
|
||||
};
|
||||
|
||||
static void PlaceProps()
|
||||
{
|
||||
var root = new GameObject(PropsRootName).transform;
|
||||
var counts = new Dictionary<string, int>();
|
||||
foreach (var p in Layout)
|
||||
{
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>($"{PropsFolder}/{p.prop}.fbx");
|
||||
if (prefab == null)
|
||||
{
|
||||
Debug.LogWarning($"Dress: missing prop {p.prop}.fbx");
|
||||
continue;
|
||||
}
|
||||
counts.TryGetValue(p.prop, out int n);
|
||||
counts[p.prop] = n + 1;
|
||||
|
||||
var go = (GameObject)PrefabUtility.InstantiatePrefab(prefab, root);
|
||||
go.name = $"{p.prop}_{n + 1}";
|
||||
go.transform.SetPositionAndRotation(p.pos, Quaternion.Euler(0, p.yaw, 0));
|
||||
GameObjectUtility.SetStaticEditorFlags(go, StaticEditorFlags.BatchingStatic | StaticEditorFlags.OccluderStatic | StaticEditorFlags.OccludeeStatic);
|
||||
AddBoxCollider(go);
|
||||
}
|
||||
}
|
||||
|
||||
// A BoxCollider added next to a MeshFilter auto-fits the mesh bounds (in that object's space),
|
||||
// so bullets and the camera ray hit props properly.
|
||||
static void AddBoxCollider(GameObject go)
|
||||
{
|
||||
var mf = go.GetComponentInChildren<MeshFilter>();
|
||||
if (mf != null && mf.GetComponent<Collider>() == null)
|
||||
mf.gameObject.AddComponent<BoxCollider>();
|
||||
}
|
||||
|
||||
// ---- Roof -----------------------------------------------------------------------
|
||||
|
||||
const float EaveHeight = 8f;
|
||||
const float RidgeHeight = 10.5f;
|
||||
const float HalfSpanX = 23f; // perimeter walls sit at x = ±22.5
|
||||
const float HalfSpanZ = 18f; // and z = ±17.5
|
||||
static readonly float[] TrussZ = { -15f, -10f, -5f, 0f, 5f, 10f, 15f };
|
||||
|
||||
// Pitched warehouse roof in the style of Valorant's range: wooden trusses across the short
|
||||
// span, steel purlins along it, a plank deck with a skylight strip on each slope.
|
||||
// The deck doesn't cast shadows (daylight still reaches the floor, standing in for light
|
||||
// pouring through the skylights); trusses and purlins do, striping the floor with shadows.
|
||||
static void BuildRoof()
|
||||
{
|
||||
var root = new GameObject(RoofRootName).transform;
|
||||
float rise = RidgeHeight - EaveHeight;
|
||||
float rafterLen = Mathf.Sqrt(HalfSpanX * HalfSpanX + rise * rise);
|
||||
float pitch = Mathf.Atan2(rise, HalfSpanX) * Mathf.Rad2Deg;
|
||||
|
||||
foreach (float z in TrussZ)
|
||||
{
|
||||
var truss = new GameObject($"Truss_{z:+0;-0;0}").transform;
|
||||
truss.SetParent(root, false);
|
||||
truss.localPosition = new Vector3(0, 0, z);
|
||||
|
||||
Beam(truss, "TieBeam", new Vector3(0, EaveHeight - 0.25f, 0), new Vector3(HalfSpanX * 2f, 0.35f, 0.3f), Quaternion.identity, woodMat);
|
||||
Beam(truss, "KingPost", new Vector3(0, (EaveHeight + RidgeHeight) / 2f - 0.1f, 0), new Vector3(0.3f, rise + 0.3f, 0.3f), Quaternion.identity, woodMat);
|
||||
foreach (int side in new[] { -1, 1 })
|
||||
{
|
||||
Vector3 mid = new Vector3(side * HalfSpanX / 2f, (EaveHeight + RidgeHeight) / 2f, 0);
|
||||
Beam(truss, side < 0 ? "Rafter_W" : "Rafter_E", mid, new Vector3(rafterLen, 0.35f, 0.3f), Quaternion.Euler(0, 0, -side * pitch), woodMat);
|
||||
// Queen posts + a diagonal strut per side.
|
||||
foreach (float x in new[] { 7.5f, 15f })
|
||||
{
|
||||
float h = RidgeHeight - rise * (x / HalfSpanX) - (EaveHeight - 0.1f);
|
||||
Beam(truss, "Post", new Vector3(side * x, EaveHeight - 0.1f + h / 2f, 0), new Vector3(0.22f, h, 0.22f), Quaternion.identity, woodMat);
|
||||
}
|
||||
Vector3 a = new Vector3(side * 7.5f, EaveHeight - 0.1f, 0);
|
||||
Vector3 b = new Vector3(side * 1f, RidgeHeight - rise / HalfSpanX - 0.25f, 0);
|
||||
Beam(truss, "Strut", (a + b) / 2f, new Vector3(Vector3.Distance(a, b), 0.2f, 0.2f), Quaternion.FromToRotation(Vector3.right, b - a), woodMat);
|
||||
}
|
||||
}
|
||||
|
||||
// Steel purlins running the length of the hall, sitting on the rafters.
|
||||
foreach (float x in new[] { -18f, -12f, -6f, 0f, 6f, 12f, 18f })
|
||||
{
|
||||
float y = RidgeHeight - rise * (Mathf.Abs(x) / HalfSpanX) + 0.2f;
|
||||
Beam(root, $"Purlin_{x:+0;-0;0}", new Vector3(x, y, 0), new Vector3(0.22f, 0.3f, HalfSpanZ * 2f), Quaternion.identity, steelMat);
|
||||
}
|
||||
|
||||
// Deck panels on each slope, leaving a skylight strip between 3 m and 6 m from the ridge.
|
||||
foreach (int side in new[] { -1, 1 })
|
||||
{
|
||||
DeckPanel(root, side, 0f, 3f, rise, pitch);
|
||||
DeckPanel(root, side, 6f, HalfSpanX, rise, pitch);
|
||||
foreach (float x in new[] { 3f, 6f })
|
||||
{
|
||||
float y = RidgeHeight - rise * (x / HalfSpanX) + 0.3f;
|
||||
Beam(root, "SkylightRail", new Vector3(side * x, y, 0), new Vector3(0.18f, 0.18f, HalfSpanZ * 2f), Quaternion.identity, steelMat);
|
||||
}
|
||||
}
|
||||
|
||||
// Gable walls close the triangles above the north/south walls.
|
||||
foreach (float z in new[] { -17.5f, 17.5f })
|
||||
Beam(root, "Gable", new Vector3(0, (EaveHeight + RidgeHeight) / 2f + 0.3f, z), new Vector3(HalfSpanX * 2f, rise + 0.6f, 1f), Quaternion.identity, wallMat);
|
||||
}
|
||||
|
||||
static void DeckPanel(Transform root, int side, float fromX, float toX, float rise, float pitch)
|
||||
{
|
||||
float midX = (fromX + toX) / 2f;
|
||||
float width = (toX - fromX) / Mathf.Cos(pitch * Mathf.Deg2Rad);
|
||||
float y = RidgeHeight - rise * (midX / HalfSpanX) + 0.45f;
|
||||
var deck = Beam(root, "Deck", new Vector3(side * midX, y, 0), new Vector3(width, 0.12f, HalfSpanZ * 2f),
|
||||
Quaternion.Euler(0, 0, -side * pitch), deckMat);
|
||||
deck.GetComponent<MeshRenderer>().shadowCastingMode = ShadowCastingMode.Off;
|
||||
}
|
||||
|
||||
static GameObject Beam(Transform parent, string name, Vector3 localPos, Vector3 size, Quaternion rot, Material mat, bool meterUV = true)
|
||||
{
|
||||
var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||
go.name = name;
|
||||
Object.DestroyImmediate(go.GetComponent<Collider>());
|
||||
go.transform.SetParent(parent, false);
|
||||
go.transform.localPosition = localPos;
|
||||
go.transform.localRotation = rot;
|
||||
go.transform.localScale = size;
|
||||
if (meterUV) go.GetComponent<MeshFilter>().sharedMesh = GetMeterCube(size);
|
||||
go.GetComponent<MeshRenderer>().sharedMaterial = mat;
|
||||
GameObjectUtility.SetStaticEditorFlags(go, StaticEditorFlags.BatchingStatic);
|
||||
return go;
|
||||
}
|
||||
|
||||
// ---- Lighting -------------------------------------------------------------------
|
||||
|
||||
static void SetupLighting()
|
||||
{
|
||||
var root = new GameObject(LightingRootName).transform;
|
||||
|
||||
// Warm, high afternoon sun slanting in through the skylights.
|
||||
var sun = RenderSettings.sun;
|
||||
if (sun != null)
|
||||
{
|
||||
sun.transform.rotation = Quaternion.Euler(52f, 118f, 0f);
|
||||
sun.intensity = 2.1f;
|
||||
sun.color = new Color(1f, 0.92f, 0.8f);
|
||||
sun.shadowStrength = 0.75f;
|
||||
}
|
||||
|
||||
// Bright warm ambient stands in for bounce light off the cream walls and floor.
|
||||
RenderSettings.ambientMode = AmbientMode.Trilight;
|
||||
RenderSettings.ambientSkyColor = new Color(0.92f, 0.85f, 0.76f);
|
||||
RenderSettings.ambientEquatorColor = new Color(0.82f, 0.72f, 0.6f);
|
||||
RenderSettings.ambientGroundColor = new Color(0.5f, 0.44f, 0.38f);
|
||||
RenderSettings.fog = true;
|
||||
RenderSettings.fogMode = FogMode.Linear;
|
||||
RenderSettings.fogColor = new Color(0.92f, 0.84f, 0.72f);
|
||||
RenderSettings.fogStartDistance = 25f;
|
||||
RenderSettings.fogEndDistance = 140f;
|
||||
DynamicGI.UpdateEnvironment();
|
||||
|
||||
// Daytime palette for the sky seen through the skylights.
|
||||
var sky = RenderSettings.skybox;
|
||||
if (sky != null && sky.shader.name == "Skybox/RangeStylizedSky")
|
||||
{
|
||||
sky.SetColor("_ZenithColor", new Color(0.32f, 0.52f, 0.85f));
|
||||
sky.SetColor("_SkyColor", new Color(0.55f, 0.72f, 0.93f));
|
||||
sky.SetColor("_HorizonColor", new Color(1f, 0.86f, 0.7f));
|
||||
sky.SetColor("_GroundColor", new Color(0.82f, 0.74f, 0.64f));
|
||||
sky.SetColor("_CloudColor", new Color(1f, 0.98f, 0.95f));
|
||||
sky.SetColor("_CloudShadowColor", new Color(0.72f, 0.74f, 0.82f));
|
||||
sky.SetFloat("_StarBrightness", 0f);
|
||||
EditorUtility.SetDirty(sky);
|
||||
}
|
||||
|
||||
// Industrial pendant lamps hanging from the roof over the street and alleys.
|
||||
var glow = GetGlowMaterial();
|
||||
foreach (float z in new[] { -10f, 0f, 10f })
|
||||
foreach (float x in new[] { -6f, 0f, 6f })
|
||||
Pendant(root, new Vector3(x, 0, z), glow, steelMat);
|
||||
|
||||
// Global post-processing.
|
||||
var volumeGo = new GameObject("PostProcessVolume");
|
||||
volumeGo.transform.SetParent(root);
|
||||
var volume = volumeGo.AddComponent<Volume>();
|
||||
volume.isGlobal = true;
|
||||
volume.priority = 1;
|
||||
volume.sharedProfile = GetPostProfile();
|
||||
|
||||
var cam = Camera.main;
|
||||
if (cam != null)
|
||||
{
|
||||
var data = cam.GetUniversalAdditionalCameraData();
|
||||
data.renderPostProcessing = true;
|
||||
data.antialiasing = AntialiasingMode.SubpixelMorphologicalAntiAliasing;
|
||||
}
|
||||
}
|
||||
|
||||
static void Pendant(Transform root, Vector3 floorPos, Material glow, Material shade)
|
||||
{
|
||||
const float lampY = 5.6f;
|
||||
var lamp = new GameObject($"Pendant_{floorPos.x:+0;-0;0}_{floorPos.z:+0;-0;0}").transform;
|
||||
lamp.SetParent(root, false);
|
||||
lamp.localPosition = new Vector3(floorPos.x, lampY, floorPos.z);
|
||||
|
||||
float cordLen = EaveHeight - 0.4f - lampY;
|
||||
Beam(lamp, "Cord", new Vector3(0, cordLen / 2f + 0.15f, 0), new Vector3(0.03f, cordLen, 0.03f), Quaternion.identity, shade, meterUV: false);
|
||||
|
||||
var shadeGo = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
|
||||
shadeGo.name = "Shade";
|
||||
Object.DestroyImmediate(shadeGo.GetComponent<Collider>());
|
||||
shadeGo.transform.SetParent(lamp, false);
|
||||
shadeGo.transform.localPosition = new Vector3(0, 0.1f, 0);
|
||||
shadeGo.transform.localScale = new Vector3(0.7f, 0.1f, 0.7f);
|
||||
shadeGo.GetComponent<MeshRenderer>().sharedMaterial = shade;
|
||||
|
||||
var bulb = GameObject.CreatePrimitive(PrimitiveType.Sphere);
|
||||
bulb.name = "Bulb";
|
||||
Object.DestroyImmediate(bulb.GetComponent<Collider>());
|
||||
bulb.transform.SetParent(lamp, false);
|
||||
bulb.transform.localPosition = new Vector3(0, -0.02f, 0);
|
||||
bulb.transform.localScale = new Vector3(0.3f, 0.16f, 0.3f);
|
||||
var br = bulb.GetComponent<MeshRenderer>();
|
||||
br.sharedMaterial = glow;
|
||||
br.shadowCastingMode = ShadowCastingMode.Off;
|
||||
|
||||
var lightGo = new GameObject("LampLight");
|
||||
lightGo.transform.SetParent(lamp, false);
|
||||
lightGo.transform.localPosition = new Vector3(0, -0.3f, 0);
|
||||
var light = lightGo.AddComponent<Light>();
|
||||
light.type = LightType.Point;
|
||||
light.color = new Color(1f, 0.8f, 0.55f);
|
||||
light.range = 10f;
|
||||
light.intensity = 5f;
|
||||
light.shadows = LightShadows.None;
|
||||
}
|
||||
|
||||
static Material GetGlowMaterial()
|
||||
{
|
||||
string path = $"{MaterialsFolder}/RangeLampGlow.mat";
|
||||
var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
|
||||
if (mat != null) return mat;
|
||||
mat = new Material(Shader.Find("Universal Render Pipeline/Lit"));
|
||||
mat.SetColor("_BaseColor", new Color(1f, 0.85f, 0.6f));
|
||||
mat.EnableKeyword("_EMISSION");
|
||||
mat.SetColor("_EmissionColor", new Color(1f, 0.72f, 0.42f) * 6f);
|
||||
mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
|
||||
AssetDatabase.CreateAsset(mat, path);
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Grading after Valorant's range: bright warm-cream whites, soft (low) contrast, lifted warm
|
||||
// shadows, slightly muted saturation, gentle bloom and almost no vignette.
|
||||
static VolumeProfile GetPostProfile()
|
||||
{
|
||||
var profile = AssetDatabase.LoadAssetAtPath<VolumeProfile>(ProfilePath);
|
||||
if (profile == null)
|
||||
{
|
||||
profile = ScriptableObject.CreateInstance<VolumeProfile>();
|
||||
AssetDatabase.CreateAsset(profile, ProfilePath);
|
||||
}
|
||||
|
||||
T Get<T>() where T : VolumeComponent
|
||||
{
|
||||
if (!profile.TryGet(out T c))
|
||||
{
|
||||
c = profile.Add<T>(true);
|
||||
c.name = typeof(T).Name;
|
||||
AssetDatabase.AddObjectToAsset(c, profile);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
Get<Tonemapping>().mode.Override(TonemappingMode.Neutral);
|
||||
|
||||
var bloom = Get<Bloom>();
|
||||
bloom.threshold.Override(1.1f);
|
||||
bloom.intensity.Override(0.35f);
|
||||
bloom.scatter.Override(0.6f);
|
||||
bloom.tint.Override(new Color(1f, 0.9f, 0.78f));
|
||||
|
||||
var color = Get<ColorAdjustments>();
|
||||
color.postExposure.Override(0.25f);
|
||||
color.contrast.Override(-10f);
|
||||
color.saturation.Override(-12f);
|
||||
color.colorFilter.Override(new Color(1f, 0.97f, 0.92f));
|
||||
|
||||
var wb = Get<WhiteBalance>();
|
||||
wb.temperature.Override(9f);
|
||||
wb.tint.Override(2f);
|
||||
|
||||
var smh = Get<ShadowsMidtonesHighlights>();
|
||||
smh.shadows.Override(new Vector4(1.02f, 0.98f, 0.94f, 0.04f)); // warm, lifted shadows
|
||||
smh.midtones.Override(new Vector4(1f, 0.99f, 0.96f, 0.02f));
|
||||
smh.highlights.Override(new Vector4(1f, 0.98f, 0.93f, 0f)); // cream highlights
|
||||
|
||||
var lgg = Get<LiftGammaGain>();
|
||||
lgg.lift.Override(new Vector4(1f, 0.98f, 0.95f, 0.05f)); // milky lift, as in the reference
|
||||
|
||||
var vignette = Get<Vignette>();
|
||||
vignette.intensity.Override(0.12f);
|
||||
vignette.smoothness.Override(0.5f);
|
||||
|
||||
EditorUtility.SetDirty(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
static void EnsureFolder(string path)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(path)) return;
|
||||
string parent = Path.GetDirectoryName(path).Replace('\\', '/');
|
||||
EnsureFolder(parent);
|
||||
AssetDatabase.CreateFolder(parent, Path.GetFileName(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 86217ae09af2f2941a17242aad0dcacd
|
||||
@@ -0,0 +1,224 @@
|
||||
using Mirror;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace ProceduralEditor
|
||||
{
|
||||
// Rebuilds PortalTestDestination.unity as a small greybox shooter-map prototype:
|
||||
// a walled arena with a central street flanked by two rows of walkable buildings,
|
||||
// in the style of a tactical-shooter blockout pass.
|
||||
public static class SetupPortalTestDestinationScene
|
||||
{
|
||||
const string ScenePath = "Assets/Scenes/PortalTestDestination.unity";
|
||||
const string MaterialsFolder = "Assets/Materials";
|
||||
const string SkyMaterialPath = "Assets/Materials/RangeSky.mat";
|
||||
|
||||
static readonly Color FloorColor = new Color(0.55f, 0.55f, 0.55f);
|
||||
static readonly Color WallColor = new Color(0.28f, 0.28f, 0.3f);
|
||||
static readonly Color BoxColor = new Color(0.76f, 0.58f, 0.34f);
|
||||
|
||||
[MenuItem("Tools/Scene/Setup Portal Test Destination Shooter Map")]
|
||||
public static void Setup()
|
||||
{
|
||||
if (EditorApplication.isPlaying)
|
||||
{
|
||||
Debug.LogError("Setup Portal Test Destination: exit Play Mode first — EditorSceneManager.OpenScene() can't run during Play Mode.");
|
||||
return;
|
||||
}
|
||||
|
||||
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
|
||||
|
||||
// Clear out the old studio/pedestal content (Geometry, Lighting rig, post-process
|
||||
// volume, probe volume data, static lighting sky) but keep Main Camera and reconfigure it.
|
||||
foreach (var existingRoot in scene.GetRootGameObjects())
|
||||
{
|
||||
if (existingRoot.name == "Main Camera") continue;
|
||||
Object.DestroyImmediate(existingRoot);
|
||||
}
|
||||
|
||||
var floorMat = GetOrCreateMaterial("ShooterProto_Floor", FloorColor);
|
||||
var wallMat = GetOrCreateMaterial("ShooterProto_Wall", WallColor);
|
||||
var boxMat = GetOrCreateMaterial("ShooterProto_Box", BoxColor);
|
||||
|
||||
SetupCamera();
|
||||
SetupLighting();
|
||||
|
||||
var root = new GameObject("ShooterMapPrototype");
|
||||
|
||||
CreateBlock(root.transform, "Floor", new Vector3(0, -0.25f, 0), new Vector3(44, 0.5f, 34), floorMat);
|
||||
|
||||
// Perimeter walls (floor spans x:[-22,22], z:[-17,17])
|
||||
CreateBlock(root.transform, "Wall_North", new Vector3(0, 2, 17.5f), new Vector3(46, 4, 1), wallMat);
|
||||
CreateBlock(root.transform, "Wall_South", new Vector3(0, 2, -17.5f), new Vector3(46, 4, 1), wallMat);
|
||||
CreateBlock(root.transform, "Wall_East", new Vector3(22.5f, 2, 0), new Vector3(1, 4, 34), wallMat);
|
||||
CreateBlock(root.transform, "Wall_West", new Vector3(-22.5f, 2, 0), new Vector3(1, 4, 34), wallMat);
|
||||
|
||||
var buildingsRoot = new GameObject("Buildings").transform;
|
||||
buildingsRoot.SetParent(root.transform);
|
||||
|
||||
// A central street (x: -4..4) runs the full length of the map between two spawns.
|
||||
// Three buildings line each side of it, each walkable with a door facing the street
|
||||
// and a door facing the back alley, so players can cut through or around them.
|
||||
const float buildingWidth = 6f;
|
||||
const float buildingDepth = 6f;
|
||||
const float wallHeight = 3f;
|
||||
const float wallThickness = 0.5f;
|
||||
const float doorWidth = 2.2f;
|
||||
|
||||
float[] rowZ = { -9f, 0f, 9f };
|
||||
for (int i = 0; i < rowZ.Length; i++)
|
||||
{
|
||||
// Left row: door faces east (street), back door faces west (outer alley)
|
||||
CreateBuilding(buildingsRoot, $"Building_L{i + 1}", new Vector3(-13, 0, rowZ[i]),
|
||||
buildingWidth, buildingDepth, wallHeight, wallThickness,
|
||||
doorEast: true, doorWest: true, doorWidth, wallMat);
|
||||
|
||||
// Right row: mirrored
|
||||
CreateBuilding(buildingsRoot, $"Building_R{i + 1}", new Vector3(13, 0, rowZ[i]),
|
||||
buildingWidth, buildingDepth, wallHeight, wallThickness,
|
||||
doorEast: true, doorWest: true, doorWidth, wallMat);
|
||||
}
|
||||
|
||||
// A couple of symmetric crates in the open street for players crossing between
|
||||
// the two building rows to duck behind.
|
||||
var streetCoverRoot = new GameObject("StreetCover").transform;
|
||||
streetCoverRoot.SetParent(root.transform);
|
||||
CreateBlock(streetCoverRoot, "StreetCover_1", new Vector3(-2, 1, -4), new Vector3(2, 2, 2), boxMat);
|
||||
CreateBlock(streetCoverRoot, "StreetCover_2", new Vector3(2, 1, 4), new Vector3(2, 2, 2), boxMat);
|
||||
|
||||
SetupSpawnPoint("SpawnPoint_A", new Vector3(0, 0, -14));
|
||||
SetupSpawnPoint("SpawnPoint_B", new Vector3(0, 0, 14));
|
||||
|
||||
EditorSceneManager.MarkSceneDirty(scene);
|
||||
Debug.Log("Portal Test Destination rebuilt as a shooter-map prototype. Review it in the Scene view, then save (Ctrl+S).");
|
||||
}
|
||||
|
||||
static void SetupCamera()
|
||||
{
|
||||
var cam = Camera.main;
|
||||
if (cam == null)
|
||||
{
|
||||
var go = new GameObject("Main Camera", typeof(Camera), typeof(AudioListener));
|
||||
go.tag = "MainCamera";
|
||||
cam = go.GetComponent<Camera>();
|
||||
}
|
||||
|
||||
// Overview framing for the editor / before a local player's camera gets reparented
|
||||
// onto them at runtime (see PlayerScript.OnStartLocalPlayer).
|
||||
cam.transform.position = new Vector3(0, 14, -26);
|
||||
cam.transform.rotation = Quaternion.Euler(24f, 0f, 0f);
|
||||
cam.fieldOfView = 60f;
|
||||
cam.clearFlags = CameraClearFlags.Skybox;
|
||||
cam.nearClipPlane = 0.1f;
|
||||
cam.farClipPlane = 200f;
|
||||
|
||||
if (cam.GetComponent<AudioListener>() == null)
|
||||
cam.gameObject.AddComponent<AudioListener>();
|
||||
}
|
||||
|
||||
static void SetupLighting()
|
||||
{
|
||||
// Low dusk sun in the north-west; the RangeSky skybox draws its disc from this light.
|
||||
var go = new GameObject("Directional Light", typeof(Light));
|
||||
var light = go.GetComponent<Light>();
|
||||
light.type = LightType.Directional;
|
||||
go.transform.rotation = Quaternion.Euler(20f, 120f, 0f);
|
||||
light.color = new Color(1f, 0.82f, 0.62f);
|
||||
light.intensity = 1.3f;
|
||||
light.shadows = LightShadows.Soft;
|
||||
RenderSettings.sun = light;
|
||||
|
||||
var sky = AssetDatabase.LoadAssetAtPath<Material>(SkyMaterialPath);
|
||||
if (sky != null) RenderSettings.skybox = sky;
|
||||
else Debug.LogWarning($"Setup Portal Test Destination: {SkyMaterialPath} not found; keeping the current skybox.");
|
||||
|
||||
// Ambient and fog pick up the sky's palette (indigo above, warm horizon).
|
||||
RenderSettings.ambientMode = AmbientMode.Trilight;
|
||||
RenderSettings.ambientSkyColor = new Color(0.42f, 0.40f, 0.62f);
|
||||
RenderSettings.ambientEquatorColor = new Color(0.62f, 0.45f, 0.42f);
|
||||
RenderSettings.ambientGroundColor = new Color(0.16f, 0.14f, 0.18f);
|
||||
RenderSettings.fog = true;
|
||||
RenderSettings.fogMode = FogMode.Linear;
|
||||
RenderSettings.fogColor = new Color(0.55f, 0.42f, 0.48f);
|
||||
RenderSettings.fogStartDistance = 30f;
|
||||
RenderSettings.fogEndDistance = 110f;
|
||||
DynamicGI.UpdateEnvironment();
|
||||
}
|
||||
|
||||
static void SetupSpawnPoint(string name, Vector3 position)
|
||||
{
|
||||
var go = new GameObject(name, typeof(NetworkStartPosition));
|
||||
go.transform.position = position;
|
||||
}
|
||||
|
||||
// Builds a walkable, roofless room: solid North/South walls and East/West walls that
|
||||
// can each optionally have a centered door gap.
|
||||
static void CreateBuilding(Transform parent, string name, Vector3 center, float width, float depth,
|
||||
float wallHeight, float wallThickness, bool doorEast, bool doorWest, float doorWidth, Material material)
|
||||
{
|
||||
var buildingRoot = new GameObject(name).transform;
|
||||
buildingRoot.SetParent(parent, false);
|
||||
buildingRoot.localPosition = center;
|
||||
|
||||
float halfWidth = width / 2f;
|
||||
float halfDepth = depth / 2f;
|
||||
float wallY = wallHeight / 2f;
|
||||
|
||||
// North/South walls run along X; extend by wallThickness so they close the corners
|
||||
// against the East/West walls instead of leaving a gap.
|
||||
CreateBlock(buildingRoot, "Wall_N", new Vector3(0, wallY, halfDepth), new Vector3(width + wallThickness, wallHeight, wallThickness), material);
|
||||
CreateBlock(buildingRoot, "Wall_S", new Vector3(0, wallY, -halfDepth), new Vector3(width + wallThickness, wallHeight, wallThickness), material);
|
||||
|
||||
CreateSideWall(buildingRoot, "Wall_E", new Vector3(halfWidth, wallY, 0), depth, wallThickness, wallHeight, doorEast, doorWidth, material);
|
||||
CreateSideWall(buildingRoot, "Wall_W", new Vector3(-halfWidth, wallY, 0), depth, wallThickness, wallHeight, doorWest, doorWidth, material);
|
||||
}
|
||||
|
||||
// An East/West wall runs along Z; a door splits it into two segments with a gap at the center.
|
||||
static void CreateSideWall(Transform parent, string name, Vector3 center, float depth, float thickness,
|
||||
float height, bool hasDoor, float doorWidth, Material material)
|
||||
{
|
||||
if (!hasDoor)
|
||||
{
|
||||
CreateBlock(parent, name, center, new Vector3(thickness, height, depth), material);
|
||||
return;
|
||||
}
|
||||
|
||||
float segmentLength = (depth - doorWidth) / 2f;
|
||||
float segmentOffset = doorWidth / 2f + segmentLength / 2f;
|
||||
CreateBlock(parent, name + "_A", center + new Vector3(0, 0, -segmentOffset), new Vector3(thickness, height, segmentLength), material);
|
||||
CreateBlock(parent, name + "_B", center + new Vector3(0, 0, segmentOffset), new Vector3(thickness, height, segmentLength), material);
|
||||
}
|
||||
|
||||
static void CreateBlock(Transform parent, string name, Vector3 position, Vector3 size, Material material)
|
||||
{
|
||||
var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||
go.name = name;
|
||||
go.transform.SetParent(parent, false);
|
||||
go.transform.localPosition = position;
|
||||
go.transform.localScale = size;
|
||||
go.GetComponent<MeshRenderer>().sharedMaterial = material;
|
||||
}
|
||||
|
||||
static Material GetOrCreateMaterial(string name, Color color)
|
||||
{
|
||||
string path = $"{MaterialsFolder}/{name}.mat";
|
||||
var existing = AssetDatabase.LoadAssetAtPath<Material>(path);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.color = color;
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(MaterialsFolder))
|
||||
AssetDatabase.CreateFolder("Assets", "Materials");
|
||||
|
||||
var shader = Shader.Find("Universal Render Pipeline/Lit");
|
||||
var mat = new Material(shader) { color = color };
|
||||
AssetDatabase.CreateAsset(mat, path);
|
||||
AssetDatabase.SaveAssets();
|
||||
return mat;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4557b29f669ca924e8fb41557e34de70
|
||||
@@ -8,6 +8,18 @@ namespace QuickStart
|
||||
public TextMesh playerNameText;
|
||||
public GameObject floatingInfo;
|
||||
|
||||
[Header("Camera")]
|
||||
public float cameraDistance = 4.5f;
|
||||
public float cameraPivotHeight = 1.6f;
|
||||
public float cameraPitchDefault = 15f;
|
||||
public float cameraPitchMin = -10f;
|
||||
public float cameraPitchMax = 65f;
|
||||
public float mouseSensitivity = 2.5f;
|
||||
|
||||
private float cameraYaw;
|
||||
private float cameraPitch;
|
||||
private bool wantsCursorLocked = true;
|
||||
|
||||
private Material playerMaterialClone;
|
||||
|
||||
[SyncVar(hook = nameof(OnNameChanged))]
|
||||
@@ -31,8 +43,17 @@ namespace QuickStart
|
||||
|
||||
public override void OnStartLocalPlayer()
|
||||
{
|
||||
Camera.main.transform.SetParent(transform);
|
||||
Camera.main.transform.localPosition = new Vector3(0, 0, 0);
|
||||
// Camera is NOT parented to the player anymore: it follows the player's position
|
||||
// every frame (see LateUpdate/UpdateCameraTransform) but its rotation is driven
|
||||
// independently by the mouse, so turning the player body no longer snaps the
|
||||
// camera around with it.
|
||||
Camera.main.transform.SetParent(null, true);
|
||||
cameraYaw = transform.eulerAngles.y;
|
||||
cameraPitch = cameraPitchDefault;
|
||||
UpdateCameraTransform();
|
||||
|
||||
wantsCursorLocked = true;
|
||||
ApplyCursorLockState();
|
||||
|
||||
// Move the floatingInfo (player name) above the player instead of below.
|
||||
// Adjust Y to fit your model height (2.0f is a good starting point for standard humanoid scale).
|
||||
@@ -128,5 +149,81 @@ namespace QuickStart
|
||||
transform.Rotate(0f, rotationDelta, 0f, Space.Self);
|
||||
transform.Translate(0f, 0f, forwardDelta, Space.Self);
|
||||
}
|
||||
|
||||
// Camera follow/look runs in LateUpdate so it reacts to the final position for the
|
||||
// frame and isn't a frame behind the player's own Update movement.
|
||||
void LateUpdate()
|
||||
{
|
||||
if (!isLocalPlayer) return;
|
||||
|
||||
var kb = UnityEngine.InputSystem.Keyboard.current;
|
||||
if (kb != null && kb.escapeKey.wasPressedThisFrame)
|
||||
{
|
||||
if (ChatUI.IsInputFieldFocused)
|
||||
{
|
||||
ChatUI.Instance?.ExitChatFocus();
|
||||
wantsCursorLocked = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
wantsCursorLocked = !wantsCursorLocked;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-assert the lock state every frame rather than trusting Cursor.lockState to
|
||||
// have stuck: the Editor Game view (and some platforms) silently drop the lock on
|
||||
// focus loss, and gating mouse-look on reading it back meant one dropped lock
|
||||
// permanently stopped the camera from responding to the mouse.
|
||||
bool shouldLockNow = wantsCursorLocked && !ChatUI.IsInputFieldFocused;
|
||||
ApplyCursorLockState(shouldLockNow);
|
||||
|
||||
if (shouldLockNow && Application.isFocused)
|
||||
{
|
||||
float mouseX;
|
||||
float mouseY;
|
||||
|
||||
var mouse = UnityEngine.InputSystem.Mouse.current;
|
||||
if (mouse != null)
|
||||
{
|
||||
Vector2 delta = mouse.delta.ReadValue();
|
||||
mouseX = delta.x * mouseSensitivity * Time.deltaTime;
|
||||
mouseY = delta.y * mouseSensitivity * Time.deltaTime;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to legacy input if the new Input System isn't available
|
||||
mouseX = Input.GetAxis("Mouse X") * mouseSensitivity;
|
||||
mouseY = Input.GetAxis("Mouse Y") * mouseSensitivity;
|
||||
}
|
||||
|
||||
cameraYaw += mouseX;
|
||||
cameraPitch = Mathf.Clamp(cameraPitch - mouseY, cameraPitchMin, cameraPitchMax);
|
||||
}
|
||||
|
||||
UpdateCameraTransform();
|
||||
}
|
||||
|
||||
void ApplyCursorLockState() => ApplyCursorLockState(wantsCursorLocked);
|
||||
|
||||
void ApplyCursorLockState(bool locked)
|
||||
{
|
||||
Cursor.lockState = locked ? CursorLockMode.Locked : CursorLockMode.None;
|
||||
Cursor.visible = !locked;
|
||||
}
|
||||
|
||||
// Orbits the camera around a pivot above the player at a fixed distance, using
|
||||
// cameraYaw/cameraPitch — never the player's own transform.rotation.
|
||||
void UpdateCameraTransform()
|
||||
{
|
||||
var cam = Camera.main;
|
||||
if (cam == null) return;
|
||||
|
||||
Quaternion orbitRotation = Quaternion.Euler(cameraPitch, cameraYaw, 0f);
|
||||
Vector3 pivot = transform.position + Vector3.up * cameraPivotHeight;
|
||||
|
||||
cam.transform.SetPositionAndRotation(
|
||||
pivot + orbitRotation * new Vector3(0f, 0f, -cameraDistance),
|
||||
orbitRotation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d1a1a1192ea0fb948bad7cd1a5bfe100
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,55 @@
|
||||
using Mirror;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
[RequireComponent(typeof(Collider))]
|
||||
[RequireComponent(typeof(NetworkIdentity))]
|
||||
public class LocalPortal : NetworkBehaviour
|
||||
{
|
||||
[SerializeField] private string destinationSceneName = "PortalTestDestination";
|
||||
[SerializeField] private string playerTag = "Player";
|
||||
|
||||
private bool transitionStarted;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Collider portalCollider = GetComponent<Collider>();
|
||||
portalCollider.isTrigger = true;
|
||||
|
||||
Debug.Log($"[LocalPortal] Initialized '{name}' in scene '{SceneManager.GetActiveScene().name}'. " +
|
||||
$"Position: {transform.position}, Trigger: {portalCollider.isTrigger}, Destination: '{destinationSceneName}'", this);
|
||||
}
|
||||
|
||||
[ServerCallback]
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
Transform root = other.transform.root;
|
||||
|
||||
Debug.Log($"[LocalPortal] Trigger entered by '{other.name}' (root: '{root.name}', tag: '{root.tag}') " +
|
||||
$"at position {root.position}. Portal position: {transform.position}. " +
|
||||
$"Current scene: '{SceneManager.GetActiveScene().name}'", this);
|
||||
|
||||
if (transitionStarted) return;
|
||||
|
||||
if (!root.CompareTag(playerTag))
|
||||
{
|
||||
Debug.Log($"[LocalPortal] Ignoring '{root.name}' because its tag is '{root.tag}', expected '{playerTag}'.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(destinationSceneName))
|
||||
{
|
||||
Debug.LogError("[LocalPortal] Destination scene is empty; scene transition cancelled.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
transitionStarted = true;
|
||||
|
||||
Debug.Log(
|
||||
$"[LocalPortal] Server changing scene from '{SceneManager.GetActiveScene().name}' " +
|
||||
$"to '{destinationSceneName}' because '{root.name}' entered the portal.",
|
||||
this);
|
||||
|
||||
NetworkManager.singleton.ServerChangeScene(destinationSceneName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c6db0d3908c87f489b0a73d378b8a48
|
||||
@@ -77,8 +77,11 @@ public class RiveLoginController : MonoBehaviour
|
||||
? usernameInput.text.Trim()
|
||||
: string.Empty;
|
||||
|
||||
Debug.Log($"[RiveLoginController] StartMultiplayerSession called. Username length: {username.Length}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
Debug.LogWarning("[RiveLoginController] Client start blocked because the username is empty.");
|
||||
if (loginUI != null && loginUI.errorText != null)
|
||||
{
|
||||
loginUI.errorText.text = "Enter a username";
|
||||
@@ -93,10 +96,14 @@ public class RiveLoginController : MonoBehaviour
|
||||
|
||||
ChatAuthenticator auth = NetworkManager.singleton.GetComponent<ChatAuthenticator>();
|
||||
if (auth != null)
|
||||
{
|
||||
Debug.Log("[RiveLoginController] Assigning username to ChatAuthenticator.");
|
||||
auth.SetPlayerName(username);
|
||||
}
|
||||
else
|
||||
Debug.LogWarning("RiveLoginController: ChatAuthenticator not found on NetworkManager");
|
||||
|
||||
Debug.Log($"[RiveLoginController] Starting client. Network address: '{NetworkManager.singleton.networkAddress}'");
|
||||
NetworkManager.singleton.StartClient();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ using System.Reflection.Metadata;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
/// Sends keyboard navigation commands to the Rive menu and
|
||||
/// activates the Play option when Enter is pressed
|
||||
/// Sends keyboard and gamepad navigation commands to the Rive menu and
|
||||
/// activates the Play option when Enter/gamepad confirm is pressed
|
||||
public class RiveNavigationController : MonoBehaviour
|
||||
{
|
||||
[Header("Rive")]
|
||||
@@ -83,25 +83,42 @@ public class RiveNavigationController : MonoBehaviour
|
||||
private void Update()
|
||||
{
|
||||
Keyboard keyboard = Keyboard.current;
|
||||
Gamepad gamepad = Gamepad.current;
|
||||
|
||||
if (keyboard == null)
|
||||
return;
|
||||
bool movedRight = false;
|
||||
bool movedLeft = false;
|
||||
bool confirmPressed = false;
|
||||
|
||||
if (keyboard.rightArrowKey.wasPressedThisFrame)
|
||||
if (keyboard != null)
|
||||
{
|
||||
movedRight |= keyboard.rightArrowKey.wasPressedThisFrame;
|
||||
movedLeft |= keyboard.leftArrowKey.wasPressedThisFrame;
|
||||
confirmPressed |= keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame;
|
||||
}
|
||||
|
||||
if (gamepad != null)
|
||||
{
|
||||
// D-pad and left stick both move the selection — leftStick.left/right are the
|
||||
// Input System's built-in synthetic buttons derived from the stick's deflection,
|
||||
// so this fires once per push just like a button, not continuously while held.
|
||||
movedRight |= gamepad.dpad.right.wasPressedThisFrame || gamepad.leftStick.right.wasPressedThisFrame;
|
||||
movedLeft |= gamepad.dpad.left.wasPressedThisFrame || gamepad.leftStick.left.wasPressedThisFrame;
|
||||
confirmPressed |= gamepad.buttonSouth.wasPressedThisFrame;
|
||||
}
|
||||
|
||||
if (movedRight)
|
||||
{
|
||||
MoveSelection(+1);
|
||||
nextTrigger?.Trigger();
|
||||
}
|
||||
|
||||
if (keyboard.leftArrowKey.wasPressedThisFrame)
|
||||
if (movedLeft)
|
||||
{
|
||||
MoveSelection(-1);
|
||||
previousTrigger?.Trigger();
|
||||
}
|
||||
|
||||
bool enterPressed = keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame;
|
||||
|
||||
if (enterPressed && selectedIndex == PlayIndex)
|
||||
if (confirmPressed && selectedIndex == PlayIndex)
|
||||
{
|
||||
if (sceneTransitionController == null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6d320e0bd134acf429e2529e7aabcd65
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,26 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Tiny self-animating effect: squeezes the object's X/Z to nothing (and stretches Y) over
|
||||
// `duration`, then destroys it. Used for the target spawn beam.
|
||||
public class FxShrink : MonoBehaviour
|
||||
{
|
||||
public float duration = 0.45f;
|
||||
public float stretch = 1.6f;
|
||||
|
||||
Vector3 startScale;
|
||||
float startTime;
|
||||
|
||||
void Start()
|
||||
{
|
||||
startScale = transform.localScale;
|
||||
startTime = Time.time;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
float t = Mathf.Clamp01((Time.time - startTime) / duration);
|
||||
float squeeze = 1f - t * t;
|
||||
transform.localScale = new Vector3(startScale.x * squeeze, startScale.y * Mathf.Lerp(1f, stretch, t), startScale.z * squeeze);
|
||||
if (t >= 1f) Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f748ccac31f7bf4f8fccf40e16d32ad
|
||||
@@ -0,0 +1,431 @@
|
||||
using System.Collections.Generic;
|
||||
using Mirror;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
// Valorant-style hitscan rifle (Vandal-like numbers), local-only prototype:
|
||||
// - Full auto while holding LMB / Right Trigger; R / X (Square) to reload; auto-reload on empty.
|
||||
// - First-shot accuracy: standing still and not spraying = perfectly accurate.
|
||||
// - Spraying kicks the camera (climbs up, then wanders sideways) and adds bloom (random cone);
|
||||
// the camera recovers back to your aim once you stop firing.
|
||||
// - Moving adds a large inaccuracy penalty (counter-strafe to stop and shoot).
|
||||
// Shots are raycast from the center of the screen (crosshair), skipping the local player's own colliders.
|
||||
// On the local player it also sets up the WeaponRig (held rifle + animations) and an over-the-shoulder camera.
|
||||
public class HitscanShooter : MonoBehaviour
|
||||
{
|
||||
[Header("Weapon")]
|
||||
public string weaponName = "Vandal (debug)";
|
||||
public float roundsPerSecond = 9.75f;
|
||||
public int magazineSize = 25;
|
||||
public float reloadTime = 2.5f;
|
||||
public float range = 150f;
|
||||
public float bodyDamage = 40f;
|
||||
public float headDamage = 160f;
|
||||
|
||||
[Header("Accuracy (degrees)")]
|
||||
public float bloomPerShot = 0.25f;
|
||||
public float maxBloom = 2f;
|
||||
public float movingInaccuracy = 4f;
|
||||
public float movingSpeedThreshold = 0.5f;
|
||||
// Time without firing before spray resets back to first-shot accuracy.
|
||||
public float sprayResetTime = 0.35f;
|
||||
|
||||
[Header("Camera recoil (degrees per shot)")]
|
||||
public float recoilUpEarly = 0.9f; // first shots climb hard
|
||||
public float recoilUpLate = 0.2f; // then the climb flattens out...
|
||||
public int recoilClimbShots = 9;
|
||||
public float recoilSideways = 0.55f; // ...and starts swaying left/right
|
||||
|
||||
[Header("Third-person aim camera")]
|
||||
public float shoulderOffset = 0.6f;
|
||||
public float aimCameraDistance = 3f;
|
||||
|
||||
[Header("Controller")]
|
||||
public float triggerThreshold = 0.5f;
|
||||
public float rumbleLow = 0.15f;
|
||||
public float rumbleHigh = 0.35f;
|
||||
public float rumbleDuration = 0.06f;
|
||||
|
||||
int ammo;
|
||||
bool reloading;
|
||||
float reloadDoneAt;
|
||||
float nextShotAt;
|
||||
float lastShotTime = -10f;
|
||||
int sprayIndex;
|
||||
|
||||
int shotsFired;
|
||||
int hits;
|
||||
int headshots;
|
||||
int kills;
|
||||
bool isMoving;
|
||||
float lastSpread;
|
||||
Vector3 lastPlayerPosition;
|
||||
bool hasLastPlayerPosition;
|
||||
bool usingGamepad;
|
||||
float rumbleStopAt = -1f;
|
||||
|
||||
float hitmarkerUntil;
|
||||
bool hitmarkerHead;
|
||||
bool hitmarkerKill;
|
||||
readonly List<string> log = new List<string>();
|
||||
|
||||
NetworkIdentity equippedPlayer;
|
||||
CharacterMovement movement;
|
||||
WeaponRig rig;
|
||||
float originalCameraDistance;
|
||||
AudioSource audioSource;
|
||||
|
||||
GUIStyle hudStyle;
|
||||
GUIStyle hitmarkerStyle;
|
||||
|
||||
void Start()
|
||||
{
|
||||
ammo = magazineSize;
|
||||
audioSource = gameObject.AddComponent<AudioSource>();
|
||||
audioSource.playOnAwake = false;
|
||||
audioSource.spatialBlend = 0f;
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
Unequip();
|
||||
StopRumble();
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
EnsureEquipped();
|
||||
UpdateMovementState();
|
||||
UpdateAimPoint();
|
||||
|
||||
// The intro cinematic owns the camera and input; no shooting until it hands control back.
|
||||
if (RangeIntroCinematic.IsPlaying) return;
|
||||
|
||||
if (rumbleStopAt > 0f && Time.time >= rumbleStopAt) StopRumble();
|
||||
|
||||
if (reloading && Time.time >= reloadDoneAt)
|
||||
{
|
||||
reloading = false;
|
||||
ammo = magazineSize;
|
||||
AddLog("Reload complete");
|
||||
}
|
||||
|
||||
if (Time.time - lastShotTime > sprayResetTime)
|
||||
sprayIndex = 0;
|
||||
|
||||
var mouse = Mouse.current;
|
||||
var kb = Keyboard.current;
|
||||
var pad = Gamepad.current;
|
||||
|
||||
bool mouseAllowed = CanUseMouse();
|
||||
bool padAllowed = CanUseGamepad();
|
||||
|
||||
bool mouseHeld = mouseAllowed && mouse != null && mouse.leftButton.isPressed;
|
||||
bool mousePressed = mouseAllowed && mouse != null && mouse.leftButton.wasPressedThisFrame;
|
||||
bool padHeld = padAllowed && pad != null && pad.rightTrigger.ReadValue() > triggerThreshold;
|
||||
bool padPressed = padAllowed && pad != null && pad.rightTrigger.wasPressedThisFrame;
|
||||
|
||||
if (padHeld || (padAllowed && pad != null && pad.buttonWest.wasPressedThisFrame)) usingGamepad = true;
|
||||
if (mouseHeld || (mouseAllowed && kb != null && kb.rKey.wasPressedThisFrame)) usingGamepad = false;
|
||||
|
||||
bool reloadPressed = (mouseAllowed && kb != null && kb.rKey.wasPressedThisFrame)
|
||||
|| (padAllowed && pad != null && pad.buttonWest.wasPressedThisFrame);
|
||||
if (reloadPressed) StartReload();
|
||||
|
||||
if (!(mouseHeld || padHeld)) return;
|
||||
if (reloading || Time.time < nextShotAt) return;
|
||||
|
||||
if (ammo <= 0)
|
||||
{
|
||||
if (mousePressed || padPressed)
|
||||
{
|
||||
audioSource.PlayOneShot(ShootingFx.EmptyClick);
|
||||
StartReload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Fire();
|
||||
}
|
||||
|
||||
// Mouse only counts while the game "has" the cursor (locked; Esc toggles it in
|
||||
// CharacterMovement). A controller has no cursor to fight over, so it only needs
|
||||
// the chat box to be unfocused.
|
||||
bool CanUseMouse() => CanUseGamepad() && Cursor.lockState == CursorLockMode.Locked;
|
||||
|
||||
bool CanUseGamepad() => Camera.main != null && !ChatUI.IsInputFieldFocused && Application.isFocused;
|
||||
|
||||
// Arms the local player once it exists (it may spawn after this scene object).
|
||||
void EnsureEquipped()
|
||||
{
|
||||
var player = NetworkClient.localPlayer;
|
||||
if (player == equippedPlayer) return;
|
||||
|
||||
Unequip();
|
||||
if (player == null) return;
|
||||
|
||||
equippedPlayer = player;
|
||||
movement = player.GetComponent<CharacterMovement>();
|
||||
if (movement != null)
|
||||
{
|
||||
originalCameraDistance = movement.cameraDistance;
|
||||
movement.cameraDistance = aimCameraDistance;
|
||||
movement.cameraShoulderOffset = shoulderOffset;
|
||||
movement.faceCameraYaw = true;
|
||||
}
|
||||
|
||||
var animator = player.GetComponentInChildren<Animator>();
|
||||
if (animator != null && animator.isHuman)
|
||||
rig = animator.gameObject.AddComponent<WeaponRig>();
|
||||
}
|
||||
|
||||
void Unequip()
|
||||
{
|
||||
if (movement != null)
|
||||
{
|
||||
movement.cameraDistance = originalCameraDistance;
|
||||
movement.cameraShoulderOffset = 0f;
|
||||
movement.faceCameraYaw = false;
|
||||
}
|
||||
if (rig != null) Destroy(rig);
|
||||
|
||||
movement = null;
|
||||
rig = null;
|
||||
equippedPlayer = null;
|
||||
}
|
||||
|
||||
void UpdateMovementState()
|
||||
{
|
||||
var player = NetworkClient.localPlayer;
|
||||
if (player == null || Time.deltaTime <= 0f)
|
||||
{
|
||||
isMoving = false;
|
||||
hasLastPlayerPosition = false;
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 pos = player.transform.position;
|
||||
if (hasLastPlayerPosition)
|
||||
{
|
||||
Vector3 delta = pos - lastPlayerPosition;
|
||||
delta.y = 0f;
|
||||
isMoving = delta.magnitude / Time.deltaTime > movingSpeedThreshold;
|
||||
}
|
||||
lastPlayerPosition = pos;
|
||||
hasLastPlayerPosition = true;
|
||||
}
|
||||
|
||||
void UpdateAimPoint()
|
||||
{
|
||||
if (rig == null || Camera.main == null) return;
|
||||
|
||||
// During the cinematic the camera is off doing its own thing; aim where the body faces.
|
||||
if (RangeIntroCinematic.IsPlaying && equippedPlayer != null)
|
||||
{
|
||||
var body = equippedPlayer.transform;
|
||||
rig.SetAimPoint(body.position + Vector3.up * 1.4f + body.forward * 20f);
|
||||
return;
|
||||
}
|
||||
|
||||
var cam = Camera.main.transform;
|
||||
var ray = new Ray(cam.position, cam.forward);
|
||||
rig.SetAimPoint(TryRaycast(ray, out RaycastHit hit) ? hit.point : ray.GetPoint(range));
|
||||
}
|
||||
|
||||
void StartReload()
|
||||
{
|
||||
if (reloading || ammo == magazineSize) return;
|
||||
reloading = true;
|
||||
reloadDoneAt = Time.time + reloadTime;
|
||||
if (rig != null) rig.PlayReload(reloadTime);
|
||||
audioSource.PlayOneShot(ShootingFx.MagOut);
|
||||
Invoke(nameof(PlayMagInSound), reloadTime * 0.75f);
|
||||
AddLog("Reloading...");
|
||||
}
|
||||
|
||||
void PlayMagInSound() => audioSource.PlayOneShot(ShootingFx.MagIn);
|
||||
|
||||
void Fire()
|
||||
{
|
||||
ammo--;
|
||||
shotsFired++;
|
||||
nextShotAt = Time.time + 1f / roundsPerSecond;
|
||||
lastShotTime = Time.time;
|
||||
|
||||
var cam = Camera.main.transform;
|
||||
|
||||
// Bloom: random cone that grows while spraying; zero on the first shot.
|
||||
float bloom = Mathf.Min(sprayIndex * bloomPerShot, maxBloom);
|
||||
float spread = bloom + (isMoving ? movingInaccuracy : 0f);
|
||||
lastSpread = spread;
|
||||
|
||||
Vector2 randomOffset = Random.insideUnitCircle * spread;
|
||||
Quaternion offset = Quaternion.AngleAxis(-randomOffset.y, cam.right)
|
||||
* Quaternion.AngleAxis(randomOffset.x, cam.up);
|
||||
Vector3 direction = offset * cam.forward;
|
||||
|
||||
ApplyRecoil();
|
||||
sprayIndex++;
|
||||
|
||||
audioSource.PlayOneShot(ShootingFx.Gunshot, 0.8f);
|
||||
if (rig != null) rig.PlayFire();
|
||||
if (usingGamepad) StartRumble();
|
||||
|
||||
Vector3 tracerStart = rig != null ? rig.Muzzle.position : cam.position;
|
||||
|
||||
if (!TryRaycast(new Ray(cam.position, direction), out RaycastHit hit))
|
||||
{
|
||||
ShootingFx.SpawnTracer(tracerStart, cam.position + direction * range);
|
||||
AddLog($"#{shotsFired} MISS (spread {spread:0.0}°)");
|
||||
return;
|
||||
}
|
||||
|
||||
ShootingFx.SpawnTracer(tracerStart, hit.point);
|
||||
|
||||
var hitbox = hit.collider.GetComponent<ShootingTargetHitbox>();
|
||||
ShootingFx.SpawnImpact(hit.point, hit.normal, fleshHit: hitbox != null);
|
||||
|
||||
if (hitbox == null || hitbox.Owner == null)
|
||||
{
|
||||
AddLog($"#{shotsFired} hit wall '{hit.collider.name}' ({hit.distance:0.0}m)");
|
||||
return;
|
||||
}
|
||||
|
||||
float damage = hitbox.IsHead ? headDamage : bodyDamage;
|
||||
bool killed = hitbox.Owner.TakeDamage(damage, direction, hitbox.IsHead);
|
||||
|
||||
hits++;
|
||||
if (hitbox.IsHead) headshots++;
|
||||
if (killed) kills++;
|
||||
|
||||
hitmarkerUntil = Time.time + 0.15f;
|
||||
hitmarkerHead = hitbox.IsHead;
|
||||
hitmarkerKill = killed;
|
||||
audioSource.PlayOneShot(killed ? ShootingFx.KillConfirm : hitbox.IsHead ? ShootingFx.HitHead : ShootingFx.HitBody);
|
||||
|
||||
string zone = hitbox.IsHead ? "HEAD" : "BODY";
|
||||
string result = killed ? " -> KILL" : $" ({Mathf.CeilToInt(hitbox.Owner.Health)} HP left)";
|
||||
AddLog($"#{shotsFired} {zone} {damage:0} dmg on {hitbox.Owner.name} ({hit.distance:0.0}m){result}");
|
||||
}
|
||||
|
||||
// Vandal-like pattern: strong vertical climb for the first shots, then a flatter climb
|
||||
// with a left/right sway. Applied to the camera, so the crosshair itself moves.
|
||||
void ApplyRecoil()
|
||||
{
|
||||
if (movement == null) return;
|
||||
|
||||
float up = sprayIndex < recoilClimbShots ? recoilUpEarly : recoilUpLate;
|
||||
float right = sprayIndex >= recoilClimbShots
|
||||
? Mathf.Sin((sprayIndex - recoilClimbShots) * 0.7f) * recoilSideways
|
||||
: Random.Range(-0.1f, 0.1f);
|
||||
movement.AddRecoil(up, right);
|
||||
}
|
||||
|
||||
void StartRumble()
|
||||
{
|
||||
var pad = Gamepad.current;
|
||||
if (pad == null) return;
|
||||
pad.SetMotorSpeeds(rumbleLow, rumbleHigh);
|
||||
rumbleStopAt = Time.time + rumbleDuration;
|
||||
}
|
||||
|
||||
void StopRumble()
|
||||
{
|
||||
rumbleStopAt = -1f;
|
||||
Gamepad.current?.SetMotorSpeeds(0f, 0f);
|
||||
}
|
||||
|
||||
// RaycastAll so the ray passes through the local player's own body (third-person camera
|
||||
// sits behind the character) and takes the first real hit after it.
|
||||
bool TryRaycast(Ray ray, out RaycastHit result)
|
||||
{
|
||||
Transform self = NetworkClient.localPlayer != null ? NetworkClient.localPlayer.transform : null;
|
||||
var allHits = Physics.RaycastAll(ray, range, ~0, QueryTriggerInteraction.Ignore);
|
||||
System.Array.Sort(allHits, (a, b) => a.distance.CompareTo(b.distance));
|
||||
|
||||
foreach (var h in allHits)
|
||||
{
|
||||
if (self != null && h.collider.transform.IsChildOf(self)) continue;
|
||||
if (h.collider.name == "DroppedMagazine") continue;
|
||||
result = h;
|
||||
return true;
|
||||
}
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
void AddLog(string line)
|
||||
{
|
||||
log.Add(line);
|
||||
if (log.Count > 8) log.RemoveAt(0);
|
||||
Debug.Log("[Shooter] " + line);
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (RangeIntroCinematic.IsPlaying) return;
|
||||
|
||||
if (hudStyle == null)
|
||||
{
|
||||
hudStyle = new GUIStyle(GUI.skin.label) { fontSize = 15, richText = true };
|
||||
hudStyle.normal.textColor = Color.white;
|
||||
hitmarkerStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold };
|
||||
}
|
||||
|
||||
DrawCrosshair();
|
||||
|
||||
float accuracy = shotsFired > 0 ? 100f * hits / shotsFired : 0f;
|
||||
float hsRate = hits > 0 ? 100f * headshots / hits : 0f;
|
||||
string ammoText = reloading
|
||||
? $"<color=orange>RELOADING {reloadDoneAt - Time.time:0.0}s</color>"
|
||||
: $"{ammo} / {magazineSize}";
|
||||
string state = CanUseMouse() ? "<color=lime>READY</color>"
|
||||
: CanUseGamepad() && Gamepad.current != null ? "<color=lime>READY (controller)</color>"
|
||||
: "<color=red>INPUT BLOCKED (Esc/chat)</color>";
|
||||
string device = usingGamepad ? "Controller" : "Mouse & Keyboard";
|
||||
string controls = usingGamepad
|
||||
? "<i>RT fire (hold = spray) · X/□ reload · Right stick aim</i>"
|
||||
: "<i>LMB fire (hold = spray) · R reload · Esc cursor</i>";
|
||||
|
||||
var text = new System.Text.StringBuilder();
|
||||
text.AppendLine($"<b>{weaponName}</b> {state} Input: {device}");
|
||||
text.AppendLine($"Ammo: {ammoText}");
|
||||
text.AppendLine($"Shots: {shotsFired} Hits: {hits} Accuracy: {accuracy:0}%");
|
||||
text.AppendLine($"Headshots: {headshots} ({hsRate:0}%) Kills: {kills}");
|
||||
text.AppendLine($"Moving: {(isMoving ? "<color=orange>YES (inaccurate)</color>" : "no")} Spray #{sprayIndex} Last spread: {lastSpread:0.0}°");
|
||||
text.AppendLine(controls);
|
||||
text.AppendLine();
|
||||
foreach (var line in log) text.AppendLine(line);
|
||||
|
||||
var rect = new Rect(10, 10, 580, 320);
|
||||
GUI.color = new Color(0, 0, 0, 0.55f);
|
||||
GUI.DrawTexture(rect, Texture2D.whiteTexture);
|
||||
GUI.color = Color.white;
|
||||
GUI.Label(new Rect(rect.x + 8, rect.y + 6, rect.width - 16, rect.height - 12), text.ToString(), hudStyle);
|
||||
}
|
||||
|
||||
void DrawCrosshair()
|
||||
{
|
||||
float cx = Screen.width * 0.5f;
|
||||
float cy = Screen.height * 0.5f;
|
||||
|
||||
// Valorant-like: small center dot plus four short lines that push out with spread.
|
||||
float gap = 4f + (Mathf.Min(sprayIndex * bloomPerShot, maxBloom) + (isMoving ? movingInaccuracy : 0f)) * 3f;
|
||||
GUI.color = Color.cyan;
|
||||
GUI.DrawTexture(new Rect(cx - 1, cy - 1, 2, 2), Texture2D.whiteTexture);
|
||||
GUI.DrawTexture(new Rect(cx - 1, cy - gap - 6, 2, 6), Texture2D.whiteTexture);
|
||||
GUI.DrawTexture(new Rect(cx - 1, cy + gap, 2, 6), Texture2D.whiteTexture);
|
||||
GUI.DrawTexture(new Rect(cx - gap - 6, cy - 1, 6, 2), Texture2D.whiteTexture);
|
||||
GUI.DrawTexture(new Rect(cx + gap, cy - 1, 6, 2), Texture2D.whiteTexture);
|
||||
|
||||
if (Time.time < hitmarkerUntil)
|
||||
{
|
||||
GUI.color = hitmarkerKill ? Color.red : hitmarkerHead ? Color.yellow : Color.white;
|
||||
hitmarkerStyle.fontSize = hitmarkerHead ? 28 : 20;
|
||||
GUI.Label(new Rect(cx - 30, cy - 30, 60, 60), "X", hitmarkerStyle);
|
||||
}
|
||||
|
||||
GUI.color = Color.white;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4a1f90128165624d8edd144bfb81070
|
||||
@@ -0,0 +1,373 @@
|
||||
using System.Collections.Generic;
|
||||
using Mirror;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
// Code-driven intro cutscene for the practice range (no Timeline assets):
|
||||
// A 0.0–3.2s Establishing: high crane sweep over the map, title card + boom.
|
||||
// B 3.2–6.0s Targets online: low tracking shot down the street while bots beam in one by one.
|
||||
// C 6.0–8.2s Hero: camera starts in front of the player, orbits over the right shoulder and
|
||||
// blends into the exact gameplay camera while the rifle is raised.
|
||||
// Outro Letterbox slides away, "GO" stinger, HUD returns, control handed back.
|
||||
// Space / Enter / gamepad A skips; F1 replays. Runs after CharacterMovement so it owns the camera.
|
||||
[DefaultExecutionOrder(200)]
|
||||
public class RangeIntroCinematic : MonoBehaviour
|
||||
{
|
||||
public static bool IsPlaying { get; private set; }
|
||||
|
||||
const float ShotAEnd = 3.2f;
|
||||
const float ShotBEnd = 6.0f;
|
||||
const float ShotCEnd = 8.2f;
|
||||
const float OutroLength = 1.0f;
|
||||
const float CutDip = 0.16f;
|
||||
|
||||
static readonly Vector3 ShotBStart = new Vector3(2.2f, 0.35f, 16f);
|
||||
static readonly Vector3 ShotBEndPos = new Vector3(-1.2f, 1.8f, 10f);
|
||||
|
||||
List<ShootingTarget> targets;
|
||||
float[] popTimes;
|
||||
int poppedCount;
|
||||
|
||||
Camera cam;
|
||||
float originalFov = 60f;
|
||||
float t;
|
||||
float prevT;
|
||||
float releasedAt = -10f;
|
||||
bool equipStarted;
|
||||
WeaponRig rig;
|
||||
CharacterMovement movement;
|
||||
|
||||
AudioSource sfx;
|
||||
AudioSource blipSource;
|
||||
|
||||
GUIStyle titleStyle;
|
||||
GUIStyle subStyle;
|
||||
GUIStyle captionStyle;
|
||||
GUIStyle goStyle;
|
||||
|
||||
public void Begin(List<ShootingTarget> rangeTargets)
|
||||
{
|
||||
targets = rangeTargets;
|
||||
|
||||
if (sfx == null)
|
||||
{
|
||||
sfx = gameObject.AddComponent<AudioSource>();
|
||||
sfx.playOnAwake = false;
|
||||
blipSource = gameObject.AddComponent<AudioSource>();
|
||||
blipSource.playOnAwake = false;
|
||||
}
|
||||
|
||||
// Bots beam in nearest-to-camera first, so the wave rolls away down the street.
|
||||
targets.Sort((a, b) => Vector3.Distance(ShotBStart, a.transform.position)
|
||||
.CompareTo(Vector3.Distance(ShotBStart, b.transform.position)));
|
||||
popTimes = new float[targets.Count];
|
||||
for (int i = 0; i < targets.Count; i++)
|
||||
{
|
||||
popTimes[i] = Mathf.Lerp(ShotAEnd + 0.35f, ShotBEnd - 0.55f, targets.Count > 1 ? i / (float)(targets.Count - 1) : 0f);
|
||||
targets[i].HideForIntro();
|
||||
}
|
||||
poppedCount = 0;
|
||||
|
||||
cam = Camera.main;
|
||||
if (cam != null) originalFov = cam.fieldOfView;
|
||||
|
||||
t = 0f;
|
||||
prevT = 0f;
|
||||
releasedAt = -10f;
|
||||
equipStarted = false;
|
||||
IsPlaying = true;
|
||||
CharacterMovement.CinematicControl = true;
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (IsPlaying)
|
||||
{
|
||||
IsPlaying = false;
|
||||
CharacterMovement.CinematicControl = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Crossed(float time) => prevT < time && t >= time;
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!IsPlaying)
|
||||
{
|
||||
var k = Keyboard.current;
|
||||
if (k != null && k.f1Key.wasPressedThisFrame && Time.time - releasedAt > OutroLength)
|
||||
Begin(targets);
|
||||
return;
|
||||
}
|
||||
|
||||
prevT = t;
|
||||
t += Time.deltaTime;
|
||||
|
||||
if (movement == null && NetworkClient.localPlayer != null)
|
||||
movement = NetworkClient.localPlayer.GetComponent<CharacterMovement>();
|
||||
if (rig == null) rig = FindAnyObjectByType<WeaponRig>();
|
||||
if (rig != null && !equipStarted) rig.SetLowered();
|
||||
|
||||
if (Crossed(0.55f)) sfx.PlayOneShot(ShootingFx.Boom, 0.9f);
|
||||
if (Crossed(ShotAEnd - 0.35f) || Crossed(ShotBEnd - 0.35f)) sfx.PlayOneShot(ShootingFx.Whoosh, 0.8f);
|
||||
|
||||
for (int i = poppedCount; i < targets.Count && t >= popTimes[i]; i++)
|
||||
{
|
||||
targets[i].PopIn();
|
||||
blipSource.pitch = 1f + i * 0.08f;
|
||||
blipSource.PlayOneShot(ShootingFx.Blip, 0.7f);
|
||||
poppedCount = i + 1;
|
||||
}
|
||||
|
||||
if (!equipStarted && t >= ShotBEnd + 0.5f && rig != null)
|
||||
{
|
||||
equipStarted = true;
|
||||
rig.PlayEquip(1.1f);
|
||||
sfx.PlayOneShot(ShootingFx.MagIn, 0.8f);
|
||||
}
|
||||
|
||||
if (WantsSkip()) Skip();
|
||||
else if (t >= ShotCEnd) Release();
|
||||
}
|
||||
|
||||
static bool WantsSkip()
|
||||
{
|
||||
var k = Keyboard.current;
|
||||
var pad = Gamepad.current;
|
||||
return (k != null && (k.spaceKey.wasPressedThisFrame || k.enterKey.wasPressedThisFrame))
|
||||
|| (pad != null && (pad.buttonSouth.wasPressedThisFrame || pad.startButton.wasPressedThisFrame));
|
||||
}
|
||||
|
||||
void Skip()
|
||||
{
|
||||
for (int i = poppedCount; i < targets.Count; i++) targets[i].PopIn();
|
||||
poppedCount = targets.Count;
|
||||
if (rig != null && !equipStarted) rig.PlayEquip(0.35f);
|
||||
equipStarted = true;
|
||||
Release();
|
||||
}
|
||||
|
||||
void Release()
|
||||
{
|
||||
IsPlaying = false;
|
||||
CharacterMovement.CinematicControl = false;
|
||||
releasedAt = Time.time;
|
||||
if (cam != null) cam.fieldOfView = originalFov;
|
||||
sfx.PlayOneShot(ShootingFx.GoStinger, 0.8f);
|
||||
}
|
||||
|
||||
// ---- Camera ------------------------------------------------------------------------
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
if (!IsPlaying) return;
|
||||
if (cam == null) cam = Camera.main;
|
||||
if (cam == null) return;
|
||||
|
||||
Vector3 pos;
|
||||
Quaternion rot;
|
||||
float fov;
|
||||
float shake;
|
||||
|
||||
if (t < ShotAEnd) ShotA(t / ShotAEnd, out pos, out rot, out fov, out shake);
|
||||
else if (t < ShotBEnd) ShotB((t - ShotAEnd) / (ShotBEnd - ShotAEnd), out pos, out rot, out fov, out shake);
|
||||
else ShotC((t - ShotBEnd) / (ShotCEnd - ShotBEnd), out pos, out rot, out fov, out shake);
|
||||
|
||||
// Subtle handheld drift.
|
||||
float nx = (Mathf.PerlinNoise(Time.time * 0.7f, 0.1f) - 0.5f) * 2f;
|
||||
float ny = (Mathf.PerlinNoise(0.3f, Time.time * 0.6f) - 0.5f) * 2f;
|
||||
rot *= Quaternion.Euler(ny * shake, nx * shake, 0f);
|
||||
|
||||
cam.transform.SetPositionAndRotation(pos, rot);
|
||||
cam.fieldOfView = fov;
|
||||
}
|
||||
|
||||
// Indoor crane sweep under the roof trusses (below the pendant lamps at 5.6 m), easing from
|
||||
// wide to a slightly tighter frame.
|
||||
static void ShotA(float u, out Vector3 pos, out Quaternion rot, out float fov, out float shake)
|
||||
{
|
||||
float e = Mathf.SmoothStep(0f, 1f, u);
|
||||
float yaw = Mathf.Lerp(215f, 150f, e);
|
||||
float radius = Mathf.Lerp(15f, 11f, e);
|
||||
float height = Mathf.Lerp(4.8f, 3.2f, e);
|
||||
pos = Quaternion.Euler(0f, yaw, 0f) * new Vector3(0f, height, -radius);
|
||||
rot = Quaternion.LookRotation(new Vector3(0f, 0.5f, Mathf.Lerp(4f, 0f, e)) - pos);
|
||||
fov = Mathf.Lerp(55f, 48f, e);
|
||||
shake = 0.25f;
|
||||
}
|
||||
|
||||
// Low dolly down the street with a dutch angle that levels out and a zoom-out.
|
||||
static void ShotB(float u, out Vector3 pos, out Quaternion rot, out float fov, out float shake)
|
||||
{
|
||||
float e = EaseInOutCubic(u);
|
||||
pos = Vector3.Lerp(ShotBStart, ShotBEndPos, e);
|
||||
Vector3 look = Vector3.Lerp(new Vector3(0f, 1f, 2f), new Vector3(0f, 1f, -6f), e);
|
||||
rot = Quaternion.LookRotation(look - pos) * Quaternion.Euler(0f, 0f, Mathf.Lerp(7f, 0f, e));
|
||||
fov = Mathf.Lerp(36f, 55f, e);
|
||||
shake = 0.35f;
|
||||
}
|
||||
|
||||
// Starts on the player's face, orbits around their right side, then settles exactly into
|
||||
// the gameplay camera so the hand-off has no pop.
|
||||
void ShotC(float u, out Vector3 pos, out Quaternion rot, out float fov, out float shake)
|
||||
{
|
||||
shake = Mathf.Lerp(0.3f, 0f, u);
|
||||
fov = Mathf.Lerp(42f, originalFov, Mathf.SmoothStep(0f, 1f, u));
|
||||
|
||||
if (movement == null)
|
||||
{
|
||||
// No local player yet: slow push toward spawn A instead.
|
||||
pos = Vector3.Lerp(new Vector3(0f, 3f, -4f), new Vector3(0f, 2.2f, -9f), Mathf.SmoothStep(0f, 1f, u));
|
||||
rot = Quaternion.LookRotation(new Vector3(0f, 1.2f, -14f) - pos);
|
||||
return;
|
||||
}
|
||||
|
||||
Transform p = movement.transform;
|
||||
Vector3 face = p.position + Vector3.up * 1.55f;
|
||||
float e = EaseInOutCubic(Mathf.Clamp01(u / 0.85f));
|
||||
float angle = Mathf.Lerp(0f, 160f, e);
|
||||
Vector3 dir = Quaternion.AngleAxis(angle, Vector3.up) * p.forward;
|
||||
float radius = Mathf.Lerp(1.8f, 3f, e);
|
||||
Vector3 orbitPos = face + dir * radius + Vector3.up * Mathf.Lerp(-0.15f, 0.6f, e);
|
||||
Vector3 lookTarget = Vector3.Lerp(face, face + p.forward * 8f, e);
|
||||
Quaternion orbitRot = Quaternion.LookRotation(lookTarget - orbitPos);
|
||||
|
||||
movement.GetGameplayCameraPose(out Vector3 gamePos, out Quaternion gameRot);
|
||||
float blend = Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(0.55f, 1f, u));
|
||||
pos = Vector3.Lerp(orbitPos, gamePos, blend);
|
||||
rot = Quaternion.Slerp(orbitRot, gameRot, blend);
|
||||
}
|
||||
|
||||
// Classic smoothstep: 0 below edge0, 1 above edge1, eased in between.
|
||||
// (Mathf.SmoothStep(a, b, x) interpolates *between* a and b instead.)
|
||||
static float Smooth(float edge0, float edge1, float x) => Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(edge0, edge1, x));
|
||||
|
||||
static float EaseInOutCubic(float x) => x < 0.5f ? 4f * x * x * x : 1f - Mathf.Pow(-2f * x + 2f, 3f) / 2f;
|
||||
|
||||
// ---- Overlay -----------------------------------------------------------------------
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
float sinceRelease = Time.time - releasedAt;
|
||||
bool inOutro = !IsPlaying && sinceRelease < OutroLength;
|
||||
if (!IsPlaying && !inOutro) return;
|
||||
|
||||
EnsureStyles();
|
||||
float w = Screen.width;
|
||||
float h = Screen.height;
|
||||
|
||||
// Fade from black at the start, quick dips at the cuts, white flash on release.
|
||||
float black = IsPlaying
|
||||
? Mathf.Max(1f - t / 0.6f, Dip(ShotAEnd), Dip(ShotBEnd))
|
||||
: 0f;
|
||||
if (black > 0f) Fill(new Rect(0, 0, w, h), new Color(0f, 0f, 0f, black));
|
||||
|
||||
// Letterbox bars: slide in over 0.5s, slide out during the outro.
|
||||
float bar = h * 0.12f * (IsPlaying ? Mathf.SmoothStep(0f, 1f, t / 0.5f) : 1f - Mathf.SmoothStep(0f, 1f, sinceRelease / 0.45f));
|
||||
Fill(new Rect(0, 0, w, bar), Color.black);
|
||||
Fill(new Rect(0, h - bar, w, bar), Color.black);
|
||||
|
||||
if (IsPlaying)
|
||||
{
|
||||
DrawTitle(w, h);
|
||||
DrawCaptions(w, h, bar);
|
||||
GUI.color = new Color(1f, 1f, 1f, 0.55f);
|
||||
GUI.Label(new Rect(w - 260, h - bar + bar * 0.5f - 12, 240, 24), "SPACE / (A) SKIP", captionStyle);
|
||||
}
|
||||
else
|
||||
{
|
||||
float flash = 1f - Mathf.Clamp01(sinceRelease / 0.18f);
|
||||
if (flash > 0f) Fill(new Rect(0, 0, w, h), new Color(1f, 1f, 1f, flash * 0.6f));
|
||||
|
||||
// "GO" punches in big and settles, then fades.
|
||||
float punch = Mathf.Lerp(1.6f, 1f, Mathf.SmoothStep(0f, 1f, sinceRelease / 0.25f));
|
||||
float alpha = 1f - Smooth(0.55f, 1f, sinceRelease / OutroLength);
|
||||
goStyle.fontSize = Mathf.RoundToInt(h * 0.16f * punch);
|
||||
GUI.color = new Color(1f, 0.9f, 0.35f, alpha);
|
||||
GUI.Label(new Rect(0, 0, w, h), "GO", goStyle);
|
||||
}
|
||||
|
||||
GUI.color = Color.white;
|
||||
}
|
||||
|
||||
float Dip(float cutTime) => 1f - Mathf.Clamp01(Mathf.Abs(t - cutTime) / CutDip);
|
||||
|
||||
// "PRACTICE RANGE" types on letter by letter, each letter dropping into place.
|
||||
void DrawTitle(float w, float h)
|
||||
{
|
||||
const string title = "PRACTICE RANGE";
|
||||
const float revealStart = 0.55f;
|
||||
const float perLetter = 0.055f;
|
||||
float fadeOut = 1f - Smooth(ShotAEnd - 0.5f, ShotAEnd - 0.1f, t);
|
||||
if (t < revealStart || fadeOut <= 0f) return;
|
||||
|
||||
titleStyle.fontSize = Mathf.RoundToInt(h * 0.085f);
|
||||
float spacing = Mathf.Lerp(h * 0.03f, h * 0.012f, Mathf.SmoothStep(0f, 1f, (t - revealStart) / 1.4f));
|
||||
var sizes = new Vector2[title.Length];
|
||||
float total = 0f;
|
||||
for (int i = 0; i < title.Length; i++)
|
||||
{
|
||||
sizes[i] = titleStyle.CalcSize(new GUIContent(title[i].ToString()));
|
||||
total += sizes[i].x + spacing;
|
||||
}
|
||||
|
||||
float x = (w - total) * 0.5f;
|
||||
float y = h * 0.38f;
|
||||
for (int i = 0; i < title.Length; i++)
|
||||
{
|
||||
float lt = Mathf.Clamp01((t - revealStart - i * perLetter) / 0.25f);
|
||||
float drop = (1f - Mathf.SmoothStep(0f, 1f, lt)) * -h * 0.04f;
|
||||
GUI.color = new Color(1f, 1f, 1f, lt * fadeOut);
|
||||
GUI.Label(new Rect(x, y + drop, sizes[i].x + 4, sizes[i].y), title[i].ToString(), titleStyle);
|
||||
x += sizes[i].x + spacing;
|
||||
}
|
||||
|
||||
float subAlpha = Mathf.Clamp01((t - 1.4f) / 0.4f) * fadeOut;
|
||||
subStyle.fontSize = Mathf.RoundToInt(h * 0.028f);
|
||||
GUI.color = new Color(0.55f, 0.9f, 1f, subAlpha);
|
||||
GUI.Label(new Rect(0, y + h * 0.1f, w, h * 0.05f), "PORTAL TEST DESTINATION · VANDAL CALIBRATION", subStyle);
|
||||
}
|
||||
|
||||
// Lower-third captions for shots B and C.
|
||||
void DrawCaptions(float w, float h, float bar)
|
||||
{
|
||||
captionStyle.fontSize = Mathf.RoundToInt(h * 0.03f);
|
||||
float y = h - bar - h * 0.07f;
|
||||
|
||||
if (t >= ShotAEnd + 0.2f && t < ShotBEnd)
|
||||
{
|
||||
float a = Mathf.Clamp01((t - ShotAEnd - 0.2f) / 0.3f) * (1f - Mathf.Clamp01((t - ShotBEnd + 0.3f) / 0.3f));
|
||||
GUI.color = new Color(0.55f, 0.9f, 1f, a);
|
||||
GUI.Label(new Rect(w * 0.06f, y, w, h * 0.05f), $"TARGETS ONLINE {poppedCount:00} / {targets.Count:00}", captionStyle);
|
||||
}
|
||||
else if (t >= ShotBEnd + 0.3f)
|
||||
{
|
||||
float a = Mathf.Clamp01((t - ShotBEnd - 0.3f) / 0.3f) * (1f - Mathf.Clamp01((t - ShotCEnd + 0.4f) / 0.3f));
|
||||
var auth = FindAnyObjectByType<ChatAuthenticator>();
|
||||
string playerName = auth != null && !string.IsNullOrEmpty(auth.playerName) ? auth.playerName.ToUpperInvariant() : "AGENT";
|
||||
GUI.color = new Color(1f, 1f, 1f, a);
|
||||
GUI.Label(new Rect(w * 0.06f, y - h * 0.045f, w, h * 0.05f), playerName, captionStyle);
|
||||
GUI.color = new Color(1f, 0.9f, 0.35f, a);
|
||||
GUI.Label(new Rect(w * 0.06f, y, w, h * 0.05f), "VANDAL · 25 RDS · HEADSHOT 160", captionStyle);
|
||||
}
|
||||
}
|
||||
|
||||
void EnsureStyles()
|
||||
{
|
||||
if (titleStyle != null) return;
|
||||
titleStyle = new GUIStyle(GUI.skin.label) { fontStyle = FontStyle.Bold, alignment = TextAnchor.UpperLeft };
|
||||
titleStyle.normal.textColor = Color.white;
|
||||
subStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold };
|
||||
subStyle.normal.textColor = Color.white;
|
||||
captionStyle = new GUIStyle(GUI.skin.label) { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft };
|
||||
captionStyle.normal.textColor = Color.white;
|
||||
goStyle = new GUIStyle(GUI.skin.label) { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter };
|
||||
goStyle.normal.textColor = Color.white;
|
||||
}
|
||||
|
||||
static void Fill(Rect r, Color c)
|
||||
{
|
||||
GUI.color = c;
|
||||
GUI.DrawTexture(r, Texture2D.whiteTexture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ab19d1d24061ade41b51644c02ff51e0
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// Procedural "assets" for the shooting prototype: tinted/emissive materials cloned from the
|
||||
// render pipeline's default material (so they work in URP without shipping .mat files), and
|
||||
// synthesized sound effects built with AudioClip.Create (no audio files needed).
|
||||
public static class ShootingFx
|
||||
{
|
||||
static Material baseMaterial;
|
||||
static readonly Dictionary<(Color, float), Material> materialCache = new Dictionary<(Color, float), Material>();
|
||||
|
||||
public static Material GetMaterial(Color color, float emission = 0f)
|
||||
{
|
||||
if (materialCache.TryGetValue((color, emission), out var cached) && cached != null)
|
||||
return cached;
|
||||
|
||||
if (baseMaterial == null)
|
||||
{
|
||||
var probe = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||
baseMaterial = probe.GetComponent<Renderer>().sharedMaterial;
|
||||
Object.Destroy(probe);
|
||||
}
|
||||
|
||||
var mat = new Material(baseMaterial) { color = color };
|
||||
if (emission > 0f)
|
||||
{
|
||||
mat.EnableKeyword("_EMISSION");
|
||||
mat.SetColor("_EmissionColor", color * emission);
|
||||
mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
|
||||
}
|
||||
|
||||
materialCache[(color, emission)] = mat;
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Primitive with its collider stripped — for visuals that must never block raycasts.
|
||||
public static GameObject CreateVisual(PrimitiveType type, string name, Transform parent,
|
||||
Vector3 localPosition, Vector3 localScale, Material material, Vector3 localEuler = default)
|
||||
{
|
||||
var go = GameObject.CreatePrimitive(type);
|
||||
go.name = name;
|
||||
Object.Destroy(go.GetComponent<Collider>());
|
||||
go.transform.SetParent(parent, false);
|
||||
go.transform.localPosition = localPosition;
|
||||
go.transform.localRotation = Quaternion.Euler(localEuler);
|
||||
go.transform.localScale = localScale;
|
||||
var renderer = go.GetComponent<Renderer>();
|
||||
renderer.sharedMaterial = material;
|
||||
renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
return go;
|
||||
}
|
||||
|
||||
// ---- Effects -------------------------------------------------------------------------
|
||||
|
||||
public static void SpawnTracer(Vector3 from, Vector3 to)
|
||||
{
|
||||
Vector3 dir = to - from;
|
||||
float length = dir.magnitude;
|
||||
if (length < 0.05f) return;
|
||||
|
||||
var tracer = CreateVisual(PrimitiveType.Cube, "Tracer", null, from + dir * 0.5f,
|
||||
new Vector3(0.015f, 0.015f, length), GetMaterial(new Color(1f, 0.85f, 0.4f), 4f));
|
||||
tracer.transform.rotation = Quaternion.LookRotation(dir);
|
||||
Object.Destroy(tracer, 0.05f);
|
||||
}
|
||||
|
||||
public static void SpawnImpact(Vector3 point, Vector3 normal, bool fleshHit)
|
||||
{
|
||||
var decal = CreateVisual(PrimitiveType.Cube, "BulletHole", null, point + normal * 0.005f,
|
||||
new Vector3(0.07f, 0.07f, 0.01f), GetMaterial(new Color(0.05f, 0.05f, 0.05f)));
|
||||
decal.transform.rotation = Quaternion.LookRotation(normal);
|
||||
Object.Destroy(decal, fleshHit ? 0.1f : 4f);
|
||||
|
||||
Color sparkColor = fleshHit ? new Color(1f, 0.2f, 0.15f) : new Color(1f, 0.75f, 0.3f);
|
||||
var sparkMat = GetMaterial(sparkColor, 3f);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var spark = CreateVisual(PrimitiveType.Cube, "Spark", null, point + normal * 0.03f,
|
||||
Vector3.one * 0.025f, sparkMat, Random.insideUnitSphere * 180f);
|
||||
var body = spark.AddComponent<Rigidbody>();
|
||||
body.useGravity = true;
|
||||
body.linearVelocity = (normal + Random.insideUnitSphere * 0.8f).normalized * Random.Range(2f, 4.5f);
|
||||
Object.Destroy(spark, Random.Range(0.25f, 0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical light column that squeezes away — "target materialized" effect.
|
||||
public static void SpawnBeam(Vector3 groundPoint)
|
||||
{
|
||||
var beam = CreateVisual(PrimitiveType.Cylinder, "SpawnBeam", null, groundPoint + Vector3.up * 1.5f,
|
||||
new Vector3(0.9f, 1.5f, 0.9f), GetMaterial(new Color(0.35f, 0.9f, 1f), 3f));
|
||||
beam.AddComponent<FxShrink>();
|
||||
}
|
||||
|
||||
// ---- Sounds --------------------------------------------------------------------------
|
||||
|
||||
const int SampleRate = 44100;
|
||||
static AudioClip gunshot, emptyClick, magOut, magIn, hitBody, hitHead, killConfirm;
|
||||
|
||||
public static AudioClip Gunshot => gunshot ??= Synth("Gunshot", 0.28f, (t, n) =>
|
||||
n * Mathf.Exp(-t * 28f) * 0.9f
|
||||
+ Mathf.Sin(2f * Mathf.PI * 95f * t) * Mathf.Exp(-t * 18f) * 0.8f
|
||||
+ Mathf.Sin(2f * Mathf.PI * 190f * t) * Mathf.Exp(-t * 40f) * 0.3f);
|
||||
|
||||
public static AudioClip EmptyClick => emptyClick ??= Synth("EmptyClick", 0.05f, (t, n) =>
|
||||
(n * 0.5f + Mathf.Sin(2f * Mathf.PI * 3200f * t)) * Mathf.Exp(-t * 120f) * 0.5f);
|
||||
|
||||
public static AudioClip MagOut => magOut ??= Synth("MagOut", 0.12f, (t, n) =>
|
||||
(n * 0.6f + Mathf.Sin(2f * Mathf.PI * 700f * t) * 0.4f) * Mathf.Exp(-t * 45f) * 0.6f);
|
||||
|
||||
public static AudioClip MagIn => magIn ??= Synth("MagIn", 0.15f, (t, n) =>
|
||||
(n * 0.5f + Mathf.Sin(2f * Mathf.PI * 1100f * t) * 0.5f) * Mathf.Exp(-t * 35f) * 0.7f
|
||||
+ (t > 0.07f ? (n * 0.6f + Mathf.Sin(2f * Mathf.PI * 1500f * t) * 0.4f) * Mathf.Exp(-(t - 0.07f) * 50f) * 0.7f : 0f));
|
||||
|
||||
public static AudioClip HitBody => hitBody ??= Synth("HitBody", 0.06f, (t, n) =>
|
||||
Mathf.Sin(2f * Mathf.PI * 1700f * t) * Mathf.Exp(-t * 60f) * 0.35f);
|
||||
|
||||
public static AudioClip HitHead => hitHead ??= Synth("HitHead", 0.18f, (t, n) =>
|
||||
(Mathf.Sin(2f * Mathf.PI * 2600f * t) + Mathf.Sin(2f * Mathf.PI * 3900f * t) * 0.5f) * Mathf.Exp(-t * 22f) * 0.35f);
|
||||
|
||||
public static AudioClip KillConfirm => killConfirm ??= Synth("Kill", 0.22f, (t, n) =>
|
||||
Mathf.Sin(2f * Mathf.PI * (t < 0.08f ? 880f : 1320f) * t) * Mathf.Exp(-(t % 0.08f) * 18f) * 0.35f);
|
||||
|
||||
static AudioClip whoosh, boom, blip, goStinger;
|
||||
|
||||
// Filtered noise that swells and fades, with the filter opening up (camera fly-by).
|
||||
public static AudioClip Whoosh => whoosh ??= SynthWhoosh();
|
||||
|
||||
static AudioClip SynthWhoosh()
|
||||
{
|
||||
float lowpass = 0f;
|
||||
return Synth("Whoosh", 0.9f, (t, n) =>
|
||||
{
|
||||
float cutoff = Mathf.Lerp(0.02f, 0.25f, Mathf.Sin(Mathf.PI * Mathf.Clamp01(t / 0.9f)));
|
||||
lowpass += (n - lowpass) * cutoff;
|
||||
float env = Mathf.Sin(Mathf.PI * Mathf.Clamp01(t / 0.9f));
|
||||
return lowpass * env * env * 1.8f;
|
||||
});
|
||||
}
|
||||
|
||||
// Sub-bass drop with a noisy transient (title hit).
|
||||
public static AudioClip Boom => boom ??= Synth("Boom", 1.6f, (t, n) =>
|
||||
{
|
||||
float freq = Mathf.Lerp(90f, 38f, Mathf.Clamp01(t / 1.2f));
|
||||
return Mathf.Sin(2f * Mathf.PI * freq * t) * Mathf.Exp(-t * 2.2f) * 0.9f
|
||||
+ n * Mathf.Exp(-t * 30f) * 0.5f;
|
||||
});
|
||||
|
||||
// Short digital blip — pitch it up per target for a rising "boot sequence".
|
||||
public static AudioClip Blip => blip ??= Synth("Blip", 0.12f, (t, n) =>
|
||||
(Mathf.Sin(2f * Mathf.PI * 880f * t) + Mathf.Sin(2f * Mathf.PI * 1760f * t) * 0.3f) * Mathf.Exp(-t * 30f) * 0.35f);
|
||||
|
||||
// Bright major chord stab for "GO".
|
||||
public static AudioClip GoStinger => goStinger ??= Synth("Go", 0.8f, (t, n) =>
|
||||
{
|
||||
float env = Mathf.Min(1f, t * 60f) * Mathf.Exp(-t * 4f);
|
||||
return (Mathf.Sin(2f * Mathf.PI * 523.25f * t) + Mathf.Sin(2f * Mathf.PI * 659.25f * t)
|
||||
+ Mathf.Sin(2f * Mathf.PI * 783.99f * t) + Mathf.Sin(2f * Mathf.PI * 1046.5f * t) * 0.5f) * env * 0.18f
|
||||
+ n * Mathf.Exp(-t * 40f) * 0.2f;
|
||||
});
|
||||
|
||||
delegate float SampleFunc(float time, float noise);
|
||||
|
||||
// Samples are generated in order, so a SampleFunc may keep state (e.g. a filter) in a closure.
|
||||
static AudioClip Synth(string name, float duration, SampleFunc func)
|
||||
{
|
||||
int count = Mathf.CeilToInt(duration * SampleRate);
|
||||
var data = new float[count];
|
||||
var rng = new System.Random(name.GetHashCode());
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
float noise = (float)(rng.NextDouble() * 2.0 - 1.0);
|
||||
data[i] = Mathf.Clamp(func(i / (float)SampleRate, noise), -1f, 1f);
|
||||
}
|
||||
|
||||
var clip = AudioClip.Create(name, count, 1, SampleRate, false);
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3fecd731e2e4aac4a9eb7dff9916965a
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
// Auto-installs the shooting prototype (targets + HitscanShooter) whenever the
|
||||
// PortalTestDestination scene loads, so no scene/prefab edits are needed. If a
|
||||
// ShootingRange is already placed in the scene by hand, the bootstrap does nothing.
|
||||
public class ShootingRange : MonoBehaviour
|
||||
{
|
||||
const string SceneName = "PortalTestDestination";
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
static void RegisterBootstrap()
|
||||
{
|
||||
SceneManager.sceneLoaded -= OnSceneLoaded;
|
||||
SceneManager.sceneLoaded += OnSceneLoaded;
|
||||
}
|
||||
|
||||
static void OnSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||
{
|
||||
if (scene.name != SceneName) return;
|
||||
if (FindAnyObjectByType<ShootingRange>() != null) return;
|
||||
|
||||
var go = new GameObject("ShootingRange");
|
||||
SceneManager.MoveGameObjectToScene(go, scene);
|
||||
go.AddComponent<ShootingRange>();
|
||||
}
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (GetComponent<HitscanShooter>() == null)
|
||||
gameObject.AddComponent<HitscanShooter>();
|
||||
|
||||
var targets = SpawnTargets();
|
||||
gameObject.AddComponent<RangeIntroCinematic>().Begin(targets);
|
||||
}
|
||||
|
||||
// Positions match the greybox map from SetupPortalTestDestinationScene: central street
|
||||
// along Z (spawns at z = ±14), buildings at x = ±13, crates at (-2,-4) and (2,4).
|
||||
List<ShootingTarget> SpawnTargets()
|
||||
{
|
||||
var root = new GameObject("Targets").transform;
|
||||
root.SetParent(transform, false);
|
||||
var list = new List<ShootingTarget>();
|
||||
|
||||
// Static bots along the street, visible from either spawn.
|
||||
list.Add(ShootingTarget.Create(root, "Target_Street_1", new Vector3(0f, 0f, 1.5f)));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Street_2", new Vector3(0f, 0f, -1.5f)));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Alley_L1", new Vector3(-6f, 0f, 8f)));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Alley_R1", new Vector3(6f, 0f, 8f)));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Alley_L2", new Vector3(-6f, 0f, -8f)));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Alley_R2", new Vector3(6f, 0f, -8f)));
|
||||
|
||||
// Inside the middle buildings (visible through the street-facing doors).
|
||||
list.Add(ShootingTarget.Create(root, "Target_Building_L", new Vector3(-13f, 0f, 0f)));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Building_R", new Vector3(13f, 0f, 0f)));
|
||||
|
||||
// Strafing bots for tracking practice.
|
||||
list.Add(ShootingTarget.Create(root, "Target_Strafe_N", new Vector3(0f, 0f, 7f), strafeDistance: 3f));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Strafe_S", new Vector3(0f, 0f, -7f), strafeDistance: 3f));
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 01bca1bcc0ae1214c8fc09d4fd5178d3
|
||||
@@ -0,0 +1,276 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Greybox practice bot built from primitives: a capsule body and a sphere head, each with a
|
||||
// ShootingTargetHitbox so the shooter knows which zone was hit. Dies at 0 HP and respawns
|
||||
// after a delay, like the Valorant range bots. Purely local (not networked).
|
||||
// Procedural animations: flinch away from each hit, fall over on death, pop back up on respawn.
|
||||
public class ShootingTarget : MonoBehaviour
|
||||
{
|
||||
public float maxHealth = 150f;
|
||||
public float respawnDelay = 2f;
|
||||
|
||||
[Header("Strafing (0 = static)")]
|
||||
public float strafeDistance = 0f;
|
||||
public float strafeSpeed = 3f;
|
||||
|
||||
static readonly Color BodyColor = new Color(0.85f, 0.25f, 0.25f);
|
||||
static readonly Color HeadColor = new Color(0.95f, 0.8f, 0.3f);
|
||||
static readonly Color FlashColor = Color.white;
|
||||
const float DeathFallTime = 0.35f;
|
||||
const float RespawnPopTime = 0.25f;
|
||||
|
||||
public float Health { get; private set; }
|
||||
public bool IsDead { get; private set; }
|
||||
|
||||
Transform visual; // pivot at the feet; every animation rotates/scales this
|
||||
Renderer[] renderers;
|
||||
Material[][] originalMaterials;
|
||||
Material[][] flashMaterials;
|
||||
bool flashApplied;
|
||||
Renderer plateRenderer;
|
||||
bool hiddenForIntro;
|
||||
Material flashMaterial;
|
||||
Collider[] colliders;
|
||||
TextMesh healthLabel;
|
||||
Vector3 startPosition;
|
||||
float flashUntil;
|
||||
float respawnAt;
|
||||
float diedAt;
|
||||
float respawnedAt = -10f;
|
||||
|
||||
// Flinch: a tilt axis/amount that springs back to 0.
|
||||
Vector3 flinchAxis = Vector3.right;
|
||||
float flinchAngle;
|
||||
float flinchVelocity;
|
||||
Vector3 deathAxis = Vector3.right;
|
||||
|
||||
public static ShootingTarget Create(Transform parent, string name, Vector3 position, float strafeDistance = 0f)
|
||||
{
|
||||
var root = new GameObject(name);
|
||||
root.transform.SetParent(parent, false);
|
||||
root.transform.localPosition = position;
|
||||
|
||||
var target = root.AddComponent<ShootingTarget>();
|
||||
target.strafeDistance = strafeDistance;
|
||||
target.Build();
|
||||
return target;
|
||||
}
|
||||
|
||||
void Build()
|
||||
{
|
||||
flashMaterial = ShootingFx.GetMaterial(FlashColor, 1.5f);
|
||||
|
||||
visual = new GameObject("Visual").transform;
|
||||
visual.SetParent(transform, false);
|
||||
|
||||
// Real model (Quaternius CC0 practice dummy) with invisible box hitboxes fitted to its
|
||||
// head/torso; falls back to the capsule + sphere bot if the model is missing.
|
||||
var model = Resources.Load<GameObject>(ModelPath);
|
||||
if (model != null) BuildFromModel(model);
|
||||
else BuildPrimitive();
|
||||
|
||||
renderers = visual.GetComponentsInChildren<Renderer>();
|
||||
originalMaterials = new Material[renderers.Length][];
|
||||
flashMaterials = new Material[renderers.Length][];
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
{
|
||||
originalMaterials[i] = renderers[i].sharedMaterials;
|
||||
flashMaterials[i] = new Material[originalMaterials[i].Length];
|
||||
for (int m = 0; m < flashMaterials[i].Length; m++) flashMaterials[i][m] = flashMaterial;
|
||||
}
|
||||
|
||||
// Base plate so the bot reads as a range dummy.
|
||||
var plate = ShootingFx.CreateVisual(PrimitiveType.Cylinder, "Base", transform, new Vector3(0f, 0.02f, 0f),
|
||||
new Vector3(0.9f, 0.02f, 0.9f), ShootingFx.GetMaterial(new Color(0.2f, 0.2f, 0.22f)));
|
||||
plateRenderer = plate.GetComponent<Renderer>();
|
||||
plateRenderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.On;
|
||||
|
||||
var label = new GameObject("HealthLabel");
|
||||
label.transform.SetParent(transform, false);
|
||||
label.transform.localPosition = new Vector3(0f, 2.25f, 0f);
|
||||
healthLabel = label.AddComponent<TextMesh>();
|
||||
healthLabel.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
|
||||
label.GetComponent<MeshRenderer>().sharedMaterial = healthLabel.font.material;
|
||||
healthLabel.characterSize = 0.08f;
|
||||
healthLabel.fontSize = 48;
|
||||
healthLabel.anchor = TextAnchor.MiddleCenter;
|
||||
healthLabel.alignment = TextAlignment.Center;
|
||||
|
||||
colliders = visual.GetComponentsInChildren<Collider>();
|
||||
startPosition = transform.localPosition;
|
||||
Respawn();
|
||||
}
|
||||
|
||||
const string ModelPath = "Targets/PracticeDummy";
|
||||
|
||||
// Hitbox zones measured from the dummy mesh (1.85 m tall): torso 0.46–1.57 m, head 1.57–1.85 m.
|
||||
void BuildFromModel(GameObject model)
|
||||
{
|
||||
var instance = Instantiate(model, visual, false);
|
||||
instance.name = model.name;
|
||||
foreach (var c in instance.GetComponentsInChildren<Collider>()) Destroy(c);
|
||||
foreach (var r in instance.GetComponentsInChildren<Renderer>()) r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.On;
|
||||
|
||||
AddBoxHitbox("Body", new Vector3(0f, 1.02f, 0f), new Vector3(0.66f, 1.1f, 0.5f), isHead: false);
|
||||
AddBoxHitbox("Head", new Vector3(0f, 1.71f, 0f), new Vector3(0.36f, 0.3f, 0.34f), isHead: true);
|
||||
}
|
||||
|
||||
void AddBoxHitbox(string name, Vector3 center, Vector3 size, bool isHead)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(visual, false);
|
||||
go.transform.localPosition = center;
|
||||
go.AddComponent<BoxCollider>().size = size;
|
||||
go.AddComponent<ShootingTargetHitbox>().Init(this, isHead);
|
||||
}
|
||||
|
||||
void BuildPrimitive()
|
||||
{
|
||||
var body = GameObject.CreatePrimitive(PrimitiveType.Capsule);
|
||||
body.name = "Body";
|
||||
body.transform.SetParent(visual, false);
|
||||
body.transform.localPosition = new Vector3(0f, 0.75f, 0f);
|
||||
body.transform.localScale = new Vector3(0.7f, 0.75f, 0.7f);
|
||||
body.AddComponent<ShootingTargetHitbox>().Init(this, isHead: false);
|
||||
body.GetComponent<Renderer>().sharedMaterial = ShootingFx.GetMaterial(BodyColor);
|
||||
|
||||
var head = GameObject.CreatePrimitive(PrimitiveType.Sphere);
|
||||
head.name = "Head";
|
||||
head.transform.SetParent(visual, false);
|
||||
head.transform.localPosition = new Vector3(0f, 1.73f, 0f);
|
||||
head.transform.localScale = Vector3.one * 0.36f;
|
||||
head.AddComponent<ShootingTargetHitbox>().Init(this, isHead: true);
|
||||
head.GetComponent<Renderer>().sharedMaterial = ShootingFx.GetMaterial(HeadColor);
|
||||
}
|
||||
|
||||
void ApplyFlash(bool flashing)
|
||||
{
|
||||
if (flashing == flashApplied) return;
|
||||
flashApplied = flashing;
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
renderers[i].sharedMaterials = flashing ? flashMaterials[i] : originalMaterials[i];
|
||||
}
|
||||
|
||||
// Returns true if this hit killed the target. hitDirection is the bullet's travel direction.
|
||||
public bool TakeDamage(float damage, Vector3 hitDirection, bool headshot)
|
||||
{
|
||||
if (IsDead) return false;
|
||||
|
||||
Health = Mathf.Max(0f, Health - damage);
|
||||
flashUntil = Time.time + 0.08f;
|
||||
|
||||
// Tilt away from the shot: rotate around the horizontal axis perpendicular to it.
|
||||
Vector3 flat = Vector3.ProjectOnPlane(hitDirection, Vector3.up);
|
||||
if (flat.sqrMagnitude > 0.0001f)
|
||||
{
|
||||
flinchAxis = Vector3.Cross(Vector3.up, flat.normalized);
|
||||
deathAxis = flinchAxis;
|
||||
}
|
||||
flinchVelocity += headshot ? 260f : 140f;
|
||||
|
||||
if (Health <= 0f)
|
||||
{
|
||||
IsDead = true;
|
||||
diedAt = Time.time;
|
||||
respawnAt = Time.time + respawnDelay;
|
||||
foreach (var c in colliders) c.enabled = false;
|
||||
healthLabel.gameObject.SetActive(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Intro cinematic: the bot starts fully hidden, then PopIn() beams it in.
|
||||
public void HideForIntro()
|
||||
{
|
||||
hiddenForIntro = true;
|
||||
visual.localScale = Vector3.one * 0.0001f;
|
||||
plateRenderer.enabled = false;
|
||||
healthLabel.gameObject.SetActive(false);
|
||||
foreach (var c in colliders) c.enabled = false;
|
||||
}
|
||||
|
||||
public void PopIn()
|
||||
{
|
||||
if (!hiddenForIntro) return;
|
||||
hiddenForIntro = false;
|
||||
plateRenderer.enabled = true;
|
||||
Respawn();
|
||||
ShootingFx.SpawnBeam(transform.position);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (hiddenForIntro) return;
|
||||
|
||||
if (IsDead)
|
||||
{
|
||||
AnimateDeath();
|
||||
if (Time.time >= respawnAt) Respawn();
|
||||
return;
|
||||
}
|
||||
|
||||
if (strafeDistance > 0f)
|
||||
{
|
||||
float offset = Mathf.PingPong(Time.time * strafeSpeed, strafeDistance * 2f) - strafeDistance;
|
||||
transform.localPosition = startPosition + Vector3.right * offset;
|
||||
}
|
||||
|
||||
// Damped spring back to upright.
|
||||
const float stiffness = 180f;
|
||||
const float damping = 14f;
|
||||
flinchVelocity += (-stiffness * flinchAngle - damping * flinchVelocity) * Time.deltaTime;
|
||||
flinchAngle += flinchVelocity * Time.deltaTime;
|
||||
|
||||
float pop = Mathf.Clamp01((Time.time - respawnedAt) / RespawnPopTime);
|
||||
float scale = EaseOutBack(pop);
|
||||
visual.localScale = new Vector3(1f, Mathf.Max(0.01f, scale), 1f);
|
||||
visual.rotation = Quaternion.AngleAxis(flinchAngle, flinchAxis);
|
||||
|
||||
bool flashing = Time.time < flashUntil;
|
||||
ApplyFlash(flashing);
|
||||
|
||||
healthLabel.text = Mathf.CeilToInt(Health).ToString();
|
||||
healthLabel.color = Color.Lerp(Color.red, Color.green, Health / maxHealth);
|
||||
|
||||
var cam = Camera.main;
|
||||
if (cam != null)
|
||||
healthLabel.transform.rotation = cam.transform.rotation;
|
||||
}
|
||||
|
||||
// Topple over backwards (away from the shooter), then sink into the floor.
|
||||
void AnimateDeath()
|
||||
{
|
||||
float t = Time.time - diedAt;
|
||||
float fall = Mathf.Clamp01(t / DeathFallTime);
|
||||
float angle = Mathf.Lerp(0f, 90f, fall * fall); // accelerate like it's falling
|
||||
visual.rotation = Quaternion.AngleAxis(angle, deathAxis);
|
||||
|
||||
float sink = Mathf.Clamp01((t - DeathFallTime - 0.6f) / 0.5f);
|
||||
visual.localPosition = Vector3.down * sink * 0.8f;
|
||||
|
||||
bool flashing = Time.time < flashUntil;
|
||||
ApplyFlash(flashing);
|
||||
}
|
||||
|
||||
void Respawn()
|
||||
{
|
||||
Health = maxHealth;
|
||||
IsDead = false;
|
||||
respawnedAt = Time.time;
|
||||
flinchAngle = 0f;
|
||||
flinchVelocity = 0f;
|
||||
visual.localPosition = Vector3.zero;
|
||||
visual.rotation = Quaternion.identity;
|
||||
foreach (var c in colliders) c.enabled = true;
|
||||
healthLabel.gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
static float EaseOutBack(float x)
|
||||
{
|
||||
const float c1 = 1.70158f;
|
||||
const float c3 = c1 + 1f;
|
||||
return 1f + c3 * Mathf.Pow(x - 1f, 3f) + c1 * Mathf.Pow(x - 1f, 2f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad667a6d0d543b04ab5081034353bdb1
|
||||
@@ -0,0 +1,14 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Marks a target collider as head or body so the shooter can apply the right damage.
|
||||
public class ShootingTargetHitbox : MonoBehaviour
|
||||
{
|
||||
public ShootingTarget Owner { get; private set; }
|
||||
public bool IsHead { get; private set; }
|
||||
|
||||
public void Init(ShootingTarget owner, bool isHead)
|
||||
{
|
||||
Owner = owner;
|
||||
IsHead = isHead;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 354c051470a8bbd4e8f3ac1d45866f8f
|
||||
@@ -0,0 +1,264 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Rifle held by the local player's humanoid (Y Bot): the AK47 model from Resources (falls back
|
||||
// to a primitive stand-in if it's missing), posed in
|
||||
// front of the right shoulder and pointed at whatever the crosshair is over, with both hands
|
||||
// pinned to it via Animator IK (needs "IK Pass" on the animator layer). Also owns the
|
||||
// procedural weapon animations: fire kick, muzzle flash, and the reload (mag drop / insert).
|
||||
// Runs after CharacterMovement so it reads the camera pose for this frame.
|
||||
[DefaultExecutionOrder(100)]
|
||||
[RequireComponent(typeof(Animator))]
|
||||
public class WeaponRig : MonoBehaviour
|
||||
{
|
||||
const string ModelPath = "Weapons/AK47";
|
||||
|
||||
public Vector3 shoulderOffset = new Vector3(0.18f, 1.45f, 0f);
|
||||
// Where the pistol grip (the gun's pivot) sits relative to the shoulder, in aim space.
|
||||
// Kept close so the Y Bot's left arm can reach the handguard.
|
||||
public Vector3 holdOffset = new Vector3(-0.02f, -0.12f, 0.24f);
|
||||
|
||||
public Transform Muzzle { get; private set; }
|
||||
|
||||
Animator animator;
|
||||
Transform gun;
|
||||
Transform rightGrip;
|
||||
Transform leftGrip;
|
||||
Transform magazine;
|
||||
Vector3 magazineRestPos;
|
||||
Transform flash;
|
||||
Light flashLight;
|
||||
|
||||
Vector3 aimPoint;
|
||||
float kickBack;
|
||||
float kickPitch;
|
||||
float flashUntil;
|
||||
|
||||
float equipProgress = 1f;
|
||||
bool equipping;
|
||||
float equipStart;
|
||||
float equipDuration;
|
||||
|
||||
bool reloading;
|
||||
float reloadStart;
|
||||
float reloadDuration;
|
||||
bool magDropped;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
animator = GetComponent<Animator>();
|
||||
BuildGun();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (gun != null) Destroy(gun.gameObject);
|
||||
}
|
||||
|
||||
// Gun root pivot is the pistol grip (right hand); forward +Z, up +Y.
|
||||
void BuildGun()
|
||||
{
|
||||
gun = new GameObject("Rifle").transform;
|
||||
gun.SetParent(transform, false);
|
||||
|
||||
// Real model (Quaternius CC0 AK47, see Resources/Weapons/AK47_LICENSE.txt), else a primitive stand-in.
|
||||
var model = Resources.Load<GameObject>(ModelPath);
|
||||
if (model != null)
|
||||
{
|
||||
var instance = Instantiate(model, gun, false).transform;
|
||||
instance.name = model.name;
|
||||
foreach (var c in instance.GetComponentsInChildren<Collider>()) Destroy(c);
|
||||
Muzzle = instance.Find("Muzzle");
|
||||
rightGrip = instance.Find("RightHandGrip");
|
||||
leftGrip = instance.Find("LeftHandGrip");
|
||||
magazine = instance.Find("Magazine");
|
||||
}
|
||||
|
||||
if (Muzzle == null || rightGrip == null || leftGrip == null || magazine == null)
|
||||
{
|
||||
if (model != null) Debug.LogWarning($"[WeaponRig] '{ModelPath}' is missing Muzzle/RightHandGrip/LeftHandGrip/Magazine; using the primitive rifle.");
|
||||
foreach (Transform child in gun) Destroy(child.gameObject);
|
||||
BuildPrimitiveGun();
|
||||
}
|
||||
|
||||
magazineRestPos = magazine.localPosition;
|
||||
|
||||
flash = ShootingFx.CreateVisual(PrimitiveType.Sphere, "MuzzleFlash", Muzzle, new Vector3(0f, 0f, 0.06f),
|
||||
new Vector3(0.09f, 0.09f, 0.2f), ShootingFx.GetMaterial(new Color(1f, 0.7f, 0.25f), 6f)).transform;
|
||||
flash.gameObject.SetActive(false);
|
||||
|
||||
flashLight = new GameObject("MuzzleLight").AddComponent<Light>();
|
||||
flashLight.transform.SetParent(Muzzle, false);
|
||||
flashLight.type = LightType.Point;
|
||||
flashLight.color = new Color(1f, 0.75f, 0.4f);
|
||||
flashLight.range = 4f;
|
||||
flashLight.intensity = 1.5f;
|
||||
flashLight.enabled = false;
|
||||
}
|
||||
|
||||
void BuildPrimitiveGun()
|
||||
{
|
||||
var dark = ShootingFx.GetMaterial(new Color(0.12f, 0.12f, 0.14f));
|
||||
var metal = ShootingFx.GetMaterial(new Color(0.32f, 0.33f, 0.36f));
|
||||
var accent = ShootingFx.GetMaterial(new Color(0.95f, 0.3f, 0.25f));
|
||||
|
||||
// Parts are laid out around the receiver; offset them so the grip sits at the root pivot.
|
||||
var body = new GameObject("PrimitiveRifle").transform;
|
||||
body.SetParent(gun, false);
|
||||
body.localPosition = new Vector3(-0.01f, 0.09f, 0.05f);
|
||||
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Receiver", body, new Vector3(0f, 0f, 0.05f), new Vector3(0.07f, 0.1f, 0.42f), dark);
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Handguard", body, new Vector3(0f, -0.005f, 0.33f), new Vector3(0.075f, 0.085f, 0.2f), metal);
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cylinder, "Barrel", body, new Vector3(0f, 0.01f, 0.5f), new Vector3(0.025f, 0.13f, 0.025f), dark, new Vector3(90f, 0f, 0f));
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Stock", body, new Vector3(0f, -0.025f, -0.26f), new Vector3(0.055f, 0.11f, 0.22f), metal);
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Grip", body, new Vector3(0f, -0.1f, -0.06f), new Vector3(0.045f, 0.12f, 0.05f), dark, new Vector3(15f, 0f, 0f));
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Sight", body, new Vector3(0f, 0.07f, 0.04f), new Vector3(0.03f, 0.04f, 0.09f), dark);
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "SightDot", body, new Vector3(0f, 0.08f, 0.09f), Vector3.one * 0.012f, ShootingFx.GetMaterial(Color.red, 3f));
|
||||
magazine = ShootingFx.CreateVisual(PrimitiveType.Cube, "Magazine", body, new Vector3(0f, -0.12f, 0.12f), new Vector3(0.045f, 0.16f, 0.08f), accent, new Vector3(-12f, 0f, 0f)).transform;
|
||||
|
||||
Muzzle = new GameObject("Muzzle").transform;
|
||||
Muzzle.SetParent(body, false);
|
||||
Muzzle.localPosition = new Vector3(0f, 0.01f, 0.64f);
|
||||
|
||||
rightGrip = new GameObject("RightHandGrip").transform;
|
||||
rightGrip.SetParent(body, false);
|
||||
rightGrip.localPosition = new Vector3(0.01f, -0.09f, -0.05f);
|
||||
|
||||
leftGrip = new GameObject("LeftHandGrip").transform;
|
||||
leftGrip.SetParent(body, false);
|
||||
leftGrip.localPosition = new Vector3(-0.01f, -0.06f, 0.32f);
|
||||
}
|
||||
|
||||
public void SetAimPoint(Vector3 point) => aimPoint = point;
|
||||
|
||||
public void PlayFire()
|
||||
{
|
||||
kickBack = Mathf.Min(kickBack + 0.05f, 0.09f);
|
||||
kickPitch = Mathf.Min(kickPitch + 5f, 12f);
|
||||
flashUntil = Time.time + 0.04f;
|
||||
flash.localRotation = Quaternion.Euler(0f, 0f, Random.Range(0f, 360f));
|
||||
flash.localScale = new Vector3(0.09f, 0.09f, 0.2f) * Random.Range(0.8f, 1.25f);
|
||||
}
|
||||
|
||||
// Holds the rifle at low-ready until PlayEquip() raises it (used by the intro cinematic).
|
||||
public void SetLowered()
|
||||
{
|
||||
equipping = false;
|
||||
equipProgress = 0f;
|
||||
}
|
||||
|
||||
public void PlayEquip(float duration)
|
||||
{
|
||||
equipping = true;
|
||||
equipStart = Time.time;
|
||||
equipDuration = Mathf.Max(0.01f, duration);
|
||||
}
|
||||
|
||||
static float EaseOutBack(float x)
|
||||
{
|
||||
const float c1 = 1.70158f;
|
||||
const float c3 = c1 + 1f;
|
||||
return 1f + c3 * Mathf.Pow(x - 1f, 3f) + c1 * Mathf.Pow(x - 1f, 2f);
|
||||
}
|
||||
|
||||
public void PlayReload(float duration)
|
||||
{
|
||||
reloading = true;
|
||||
reloadStart = Time.time;
|
||||
reloadDuration = duration;
|
||||
magDropped = false;
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
float dt = Time.deltaTime;
|
||||
kickBack = Mathf.Lerp(kickBack, 0f, 1f - Mathf.Exp(-18f * dt));
|
||||
kickPitch = Mathf.Lerp(kickPitch, 0f, 1f - Mathf.Exp(-14f * dt));
|
||||
|
||||
bool flashing = Time.time < flashUntil;
|
||||
flash.gameObject.SetActive(flashing);
|
||||
flashLight.enabled = flashing;
|
||||
|
||||
// Pose: in front of the right shoulder, pointed at the crosshair target.
|
||||
Vector3 shoulder = transform.TransformPoint(shoulderOffset);
|
||||
Vector3 toAim = aimPoint - shoulder;
|
||||
Quaternion aim = toAim.sqrMagnitude > 0.5f ? Quaternion.LookRotation(toAim) : transform.rotation;
|
||||
|
||||
float reloadTilt = 0f;
|
||||
if (reloading) reloadTilt = UpdateReload();
|
||||
|
||||
// Equip: 1 = fully lowered (muzzle down at the hip), 0 = raised. Overshoots slightly on the way up.
|
||||
if (equipping)
|
||||
{
|
||||
equipProgress = Mathf.Clamp01((Time.time - equipStart) / equipDuration);
|
||||
if (equipProgress >= 1f) equipping = false;
|
||||
}
|
||||
float lowered = 1f - EaseOutBack(equipProgress);
|
||||
|
||||
Quaternion rotation = aim
|
||||
* Quaternion.Euler(-kickPitch + reloadTilt * 25f + lowered * 55f, lowered * -15f, reloadTilt * -40f + lowered * -20f);
|
||||
Vector3 position = shoulder + aim * (holdOffset + Vector3.back * (kickBack + lowered * 0.08f)
|
||||
+ Vector3.down * (reloadTilt * 0.08f + lowered * 0.25f));
|
||||
gun.SetPositionAndRotation(position, rotation);
|
||||
}
|
||||
|
||||
// Returns 0..1 "how far the gun is tilted for the reload" and drives the magazine.
|
||||
float UpdateReload()
|
||||
{
|
||||
float t = (Time.time - reloadStart) / reloadDuration;
|
||||
if (t >= 1f)
|
||||
{
|
||||
reloading = false;
|
||||
magazine.localPosition = magazineRestPos;
|
||||
magazine.gameObject.SetActive(true);
|
||||
return 0f;
|
||||
}
|
||||
|
||||
// Tilt in over the first 15%, hold, tilt back out over the last 15%.
|
||||
float tilt = Mathf.SmoothStep(0f, 1f, Mathf.Min(t / 0.15f, (1f - t) / 0.15f, 1f));
|
||||
|
||||
if (t >= 0.25f && !magDropped)
|
||||
{
|
||||
magDropped = true;
|
||||
DropMagazineCopy();
|
||||
magazine.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
if (t >= 0.55f)
|
||||
{
|
||||
// New mag slides up into the well.
|
||||
magazine.gameObject.SetActive(true);
|
||||
float insert = Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(0.55f, 0.8f, t));
|
||||
magazine.localPosition = magazineRestPos + Vector3.down * (0.25f * (1f - insert));
|
||||
}
|
||||
|
||||
return tilt;
|
||||
}
|
||||
|
||||
void DropMagazineCopy()
|
||||
{
|
||||
var dropped = Instantiate(magazine.gameObject, magazine.position, magazine.rotation);
|
||||
dropped.name = "DroppedMagazine";
|
||||
dropped.transform.localScale = magazine.lossyScale;
|
||||
dropped.AddComponent<BoxCollider>();
|
||||
var body = dropped.AddComponent<Rigidbody>();
|
||||
body.linearVelocity = -gun.up * 1.5f;
|
||||
body.angularVelocity = Random.insideUnitSphere * 5f;
|
||||
Destroy(dropped, 4f);
|
||||
}
|
||||
|
||||
void OnAnimatorIK(int layerIndex)
|
||||
{
|
||||
if (gun == null) return;
|
||||
|
||||
animator.SetIKPositionWeight(AvatarIKGoal.RightHand, 1f);
|
||||
animator.SetIKPosition(AvatarIKGoal.RightHand, rightGrip.position);
|
||||
|
||||
// During the reload the left hand goes to the magazine well instead of the handguard.
|
||||
Vector3 leftTarget = reloading ? magazine.position : leftGrip.position;
|
||||
animator.SetIKPositionWeight(AvatarIKGoal.LeftHand, 1f);
|
||||
animator.SetIKPosition(AvatarIKGoal.LeftHand, leftTarget);
|
||||
|
||||
animator.SetLookAtWeight(1f, 0.35f, 0.8f, 0f, 0.6f);
|
||||
animator.SetLookAtPosition(aimPoint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c44dd493661648469b3030de85535d9
|
||||
Reference in New Issue
Block a user