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:
pelpanagiotis
2026-09-25 08:56:35 +03:00
co-authored by Claude Opus 5.5
parent c674d76549
commit 5fc9ebf1e6
211 changed files with 34492 additions and 177 deletions
+202 -45
View File
@@ -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
+13 -5
View File
@@ -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();
+7 -1
View File
@@ -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]
+12
View File
@@ -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)
{
+7
View File
@@ -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
+99 -2
View File
@@ -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);
}
}
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d1a1a1192ea0fb948bad7cd1a5bfe100
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+55
View File
@@ -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
+7
View File
@@ -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();
}
}
+26 -9
View File
@@ -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)
{
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6d320e0bd134acf429e2529e7aabcd65
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+26
View File
@@ -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);
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7f748ccac31f7bf4f8fccf40e16d32ad
+431
View File
@@ -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
+180
View File
@@ -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
+63
View File
@@ -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
+276
View File
@@ -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
+264
View File
@@ -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