Characters
This commit is contained in:
8
Game/Scripts/Combat.meta
Normal file
8
Game/Scripts/Combat.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 43cbd7a1d6f0054838d3503a2cc2b68f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
27
Game/Scripts/Combat/DamagePayload.cs
Normal file
27
Game/Scripts/Combat/DamagePayload.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundles damage information so different systems can respond consistently.
|
||||
/// </summary>
|
||||
public struct DamagePayload
|
||||
{
|
||||
public float Amount;
|
||||
public Vector3 HitPoint;
|
||||
public Vector3 HitNormal;
|
||||
public GameObject Source;
|
||||
public Team SourceTeam;
|
||||
public GameObject Target;
|
||||
|
||||
public DamagePayload(float amount, Vector3 hitPoint, Vector3 hitNormal, GameObject source, Team sourceTeam, GameObject target)
|
||||
{
|
||||
Amount = amount;
|
||||
HitPoint = hitPoint;
|
||||
HitNormal = hitNormal;
|
||||
Source = source;
|
||||
SourceTeam = sourceTeam;
|
||||
Target = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Combat/DamagePayload.cs.meta
Normal file
2
Game/Scripts/Combat/DamagePayload.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7e3b00f0d0eff2b0e813e41895012e02
|
||||
97
Game/Scripts/Combat/Health.cs
Normal file
97
Game/Scripts/Combat/Health.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
|
||||
namespace MegaKoop.Game.Combat
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public class Health : MonoBehaviour, IDamageable
|
||||
{
|
||||
[SerializeField] private float maxHealth = 100f;
|
||||
[SerializeField] private Team team = Team.Neutral;
|
||||
[SerializeField] private bool ignoreFriendlyFire = true;
|
||||
[SerializeField] private bool destroyOnDeath = false;
|
||||
[SerializeField] private UnityEvent<float> onHealthChanged;
|
||||
[SerializeField] private UnityEvent onDeath;
|
||||
|
||||
public float MaxHealth => maxHealth;
|
||||
public float CurrentHealth { get; private set; }
|
||||
public Team Team => team;
|
||||
public bool IsAlive => CurrentHealth > 0f;
|
||||
|
||||
public event Action<float> NormalizedHealthChanged;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
maxHealth = Mathf.Max(1f, maxHealth);
|
||||
CurrentHealth = maxHealth;
|
||||
onHealthChanged?.Invoke(CurrentHealth / MaxHealth);
|
||||
NormalizedHealthChanged?.Invoke(CurrentHealth / MaxHealth);
|
||||
}
|
||||
|
||||
public void ApplyDamage(DamagePayload payload)
|
||||
{
|
||||
if (!IsAlive || payload.Amount <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ignoreFriendlyFire && team != Team.Neutral && payload.SourceTeam == team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentHealth = Mathf.Max(0f, CurrentHealth - payload.Amount);
|
||||
float normalized = CurrentHealth / MaxHealth;
|
||||
onHealthChanged?.Invoke(normalized);
|
||||
NormalizedHealthChanged?.Invoke(normalized);
|
||||
|
||||
if (CurrentHealth <= 0f)
|
||||
{
|
||||
HandleDeath();
|
||||
}
|
||||
}
|
||||
|
||||
public void Heal(float amount)
|
||||
{
|
||||
if (amount <= 0f || !IsAlive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentHealth = Mathf.Min(MaxHealth, CurrentHealth + amount);
|
||||
float normalized = CurrentHealth / MaxHealth;
|
||||
onHealthChanged?.Invoke(normalized);
|
||||
NormalizedHealthChanged?.Invoke(normalized);
|
||||
}
|
||||
|
||||
private void HandleDeath()
|
||||
{
|
||||
onDeath?.Invoke();
|
||||
|
||||
if (destroyOnDeath)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
maxHealth = Mathf.Max(1f, maxHealth);
|
||||
}
|
||||
|
||||
public void ForceSetNormalizedHealth(float normalized)
|
||||
{
|
||||
normalized = Mathf.Clamp01(normalized);
|
||||
bool wasAlive = IsAlive;
|
||||
CurrentHealth = normalized * MaxHealth;
|
||||
onHealthChanged?.Invoke(normalized);
|
||||
NormalizedHealthChanged?.Invoke(normalized);
|
||||
|
||||
if (CurrentHealth <= 0f && wasAlive)
|
||||
{
|
||||
HandleDeath();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Combat/Health.cs.meta
Normal file
2
Game/Scripts/Combat/Health.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 920077a2cfc4cde58a4ceeea4b96ebe9
|
||||
9
Game/Scripts/Combat/IDamageable.cs
Normal file
9
Game/Scripts/Combat/IDamageable.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace MegaKoop.Game.Combat
|
||||
{
|
||||
public interface IDamageable
|
||||
{
|
||||
Team Team { get; }
|
||||
bool IsAlive { get; }
|
||||
void ApplyDamage(DamagePayload payload);
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Combat/IDamageable.cs.meta
Normal file
2
Game/Scripts/Combat/IDamageable.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2044a5c09d25d50beb90c486ace32d50
|
||||
9
Game/Scripts/Combat/Team.cs
Normal file
9
Game/Scripts/Combat/Team.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace MegaKoop.Game.Combat
|
||||
{
|
||||
public enum Team
|
||||
{
|
||||
Neutral = 0,
|
||||
Heroes = 1,
|
||||
Enemies = 2
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Combat/Team.cs.meta
Normal file
2
Game/Scripts/Combat/Team.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 620167bdb0a57e180b2bee989e9ee5d1
|
||||
8
Game/Scripts/Networking.meta
Normal file
8
Game/Scripts/Networking.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: caf731eed00799f15a7315a669658012
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
10
Game/Scripts/Networking/ICharacterInputSource.cs
Normal file
10
Game/Scripts/Networking/ICharacterInputSource.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
public interface ICharacterInputSource
|
||||
{
|
||||
Vector2 MoveInput { get; }
|
||||
bool JumpPressed { get; }
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Networking/ICharacterInputSource.cs.meta
Normal file
2
Game/Scripts/Networking/ICharacterInputSource.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 403a3945388ae5082bf455368a91a800
|
||||
34
Game/Scripts/Networking/NetworkCharacterInputProxy.cs
Normal file
34
Game/Scripts/Networking/NetworkCharacterInputProxy.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
public class NetworkCharacterInputProxy : MonoBehaviour, ICharacterInputSource
|
||||
{
|
||||
public Vector2 MoveInput { get; private set; }
|
||||
public bool JumpPressed { get; private set; }
|
||||
|
||||
private bool jumpConsumed;
|
||||
|
||||
public void SetInput(Vector2 move, bool jump)
|
||||
{
|
||||
MoveInput = move;
|
||||
if (jump)
|
||||
{
|
||||
JumpPressed = true;
|
||||
jumpConsumed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (JumpPressed && !jumpConsumed)
|
||||
{
|
||||
jumpConsumed = true;
|
||||
}
|
||||
else if (jumpConsumed)
|
||||
{
|
||||
JumpPressed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3118c9c432a7acbd824645749251552
|
||||
53
Game/Scripts/Networking/NetworkIdentity.cs
Normal file
53
Game/Scripts/Networking/NetworkIdentity.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public class NetworkIdentity : MonoBehaviour
|
||||
{
|
||||
private static readonly Dictionary<int, NetworkIdentity> registry = new();
|
||||
private static int nextId = 1;
|
||||
|
||||
[SerializeField] private int networkId;
|
||||
[SerializeField] private bool assignOnAwake = true;
|
||||
|
||||
public int NetworkId => networkId;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (assignOnAwake && networkId == 0)
|
||||
{
|
||||
networkId = nextId++;
|
||||
}
|
||||
|
||||
Register();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (registry.TryGetValue(networkId, out NetworkIdentity existing) && existing == this)
|
||||
{
|
||||
registry.Remove(networkId);
|
||||
}
|
||||
}
|
||||
|
||||
private void Register()
|
||||
{
|
||||
if (networkId == 0)
|
||||
{
|
||||
Debug.LogWarning($"[NetworkIdentity] {name} has no network id and won't be tracked.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (registry.TryGetValue(networkId, out NetworkIdentity existing) && existing != this)
|
||||
{
|
||||
Debug.LogWarning($"[NetworkIdentity] Duplicate network id {networkId} detected. Overwriting reference.");
|
||||
}
|
||||
|
||||
registry[networkId] = this;
|
||||
}
|
||||
|
||||
public static bool TryGet(int id, out NetworkIdentity identity) => registry.TryGetValue(id, out identity);
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Networking/NetworkIdentity.cs.meta
Normal file
2
Game/Scripts/Networking/NetworkIdentity.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1e6f99ca4475ead269829ba672213d5c
|
||||
222
Game/Scripts/Networking/NetworkMessages.cs
Normal file
222
Game/Scripts/Networking/NetworkMessages.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
public enum NetworkMessageType : byte
|
||||
{
|
||||
Heartbeat = 0,
|
||||
LobbyState = 1,
|
||||
PlayerInput = 2,
|
||||
CharacterTransform = 3,
|
||||
WeaponFire = 4,
|
||||
HealthSync = 5,
|
||||
ProjectileSpawn = 6
|
||||
}
|
||||
|
||||
public readonly struct NetworkMessage
|
||||
{
|
||||
public readonly NetworkMessageType Type;
|
||||
public readonly byte[] Payload;
|
||||
public readonly ulong Sender;
|
||||
|
||||
public NetworkMessage(NetworkMessageType type, byte[] payload, ulong sender)
|
||||
{
|
||||
Type = type;
|
||||
Payload = payload;
|
||||
Sender = sender;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct PlayerInputMessage
|
||||
{
|
||||
public readonly int NetworkId;
|
||||
public readonly Vector2 MoveInput;
|
||||
public readonly bool JumpPressed;
|
||||
public readonly Vector2 LookDelta;
|
||||
|
||||
public PlayerInputMessage(int networkId, Vector2 moveInput, bool jumpPressed, Vector2 lookDelta)
|
||||
{
|
||||
NetworkId = networkId;
|
||||
MoveInput = moveInput;
|
||||
JumpPressed = jumpPressed;
|
||||
LookDelta = lookDelta;
|
||||
}
|
||||
|
||||
public static byte[] Serialize(PlayerInputMessage message)
|
||||
{
|
||||
using var writer = new NetworkWriter();
|
||||
writer.Write(message.NetworkId);
|
||||
writer.Write(message.MoveInput.x);
|
||||
writer.Write(message.MoveInput.y);
|
||||
writer.Write(message.JumpPressed);
|
||||
writer.Write(message.LookDelta.x);
|
||||
writer.Write(message.LookDelta.y);
|
||||
return writer.ToArray();
|
||||
}
|
||||
|
||||
public static PlayerInputMessage Deserialize(byte[] buffer)
|
||||
{
|
||||
using var reader = new NetworkReader(buffer);
|
||||
int id = reader.ReadInt();
|
||||
float moveX = reader.ReadFloat();
|
||||
float moveY = reader.ReadFloat();
|
||||
bool jump = reader.ReadBool();
|
||||
float lookX = reader.ReadFloat();
|
||||
float lookY = reader.ReadFloat();
|
||||
return new PlayerInputMessage(id, new Vector2(moveX, moveY), jump, new Vector2(lookX, lookY));
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct CharacterTransformMessage
|
||||
{
|
||||
public readonly int NetworkId;
|
||||
public readonly Vector3 Position;
|
||||
public readonly Quaternion Rotation;
|
||||
public readonly Vector3 Velocity;
|
||||
|
||||
public CharacterTransformMessage(int networkId, Vector3 position, Quaternion rotation, Vector3 velocity)
|
||||
{
|
||||
NetworkId = networkId;
|
||||
Position = position;
|
||||
Rotation = rotation;
|
||||
Velocity = velocity;
|
||||
}
|
||||
|
||||
public static byte[] Serialize(CharacterTransformMessage message)
|
||||
{
|
||||
using var writer = new NetworkWriter();
|
||||
writer.Write(message.NetworkId);
|
||||
writer.Write(message.Position);
|
||||
writer.Write(message.Rotation);
|
||||
writer.Write(message.Velocity);
|
||||
return writer.ToArray();
|
||||
}
|
||||
|
||||
public static CharacterTransformMessage Deserialize(byte[] buffer)
|
||||
{
|
||||
using var reader = new NetworkReader(buffer);
|
||||
int id = reader.ReadInt();
|
||||
Vector3 position = reader.ReadVector3();
|
||||
Quaternion rotation = reader.ReadQuaternion();
|
||||
Vector3 velocity = reader.ReadVector3();
|
||||
return new CharacterTransformMessage(id, position, rotation, velocity);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct WeaponFireMessage
|
||||
{
|
||||
public readonly int NetworkId;
|
||||
public readonly int WeaponIndex;
|
||||
public readonly Vector3 MuzzlePosition;
|
||||
public readonly Vector3 Direction;
|
||||
public readonly float Timestamp;
|
||||
|
||||
public WeaponFireMessage(int networkId, int weaponIndex, Vector3 muzzlePosition, Vector3 direction, float timestamp)
|
||||
{
|
||||
NetworkId = networkId;
|
||||
WeaponIndex = weaponIndex;
|
||||
MuzzlePosition = muzzlePosition;
|
||||
Direction = direction;
|
||||
Timestamp = timestamp;
|
||||
}
|
||||
|
||||
public static byte[] Serialize(WeaponFireMessage message)
|
||||
{
|
||||
using var writer = new NetworkWriter();
|
||||
writer.Write(message.NetworkId);
|
||||
writer.Write(message.WeaponIndex);
|
||||
writer.Write(message.MuzzlePosition);
|
||||
writer.Write(message.Direction);
|
||||
writer.Write(message.Timestamp);
|
||||
return writer.ToArray();
|
||||
}
|
||||
|
||||
public static WeaponFireMessage Deserialize(byte[] buffer)
|
||||
{
|
||||
using var reader = new NetworkReader(buffer);
|
||||
int networkId = reader.ReadInt();
|
||||
int index = reader.ReadInt();
|
||||
Vector3 muzzle = reader.ReadVector3();
|
||||
Vector3 direction = reader.ReadVector3();
|
||||
float time = reader.ReadFloat();
|
||||
return new WeaponFireMessage(networkId, index, muzzle, direction, time);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct HealthSyncMessage
|
||||
{
|
||||
public readonly int NetworkId;
|
||||
public readonly float NormalizedHealth;
|
||||
|
||||
public HealthSyncMessage(int networkId, float normalizedHealth)
|
||||
{
|
||||
NetworkId = networkId;
|
||||
NormalizedHealth = normalizedHealth;
|
||||
}
|
||||
|
||||
public static byte[] Serialize(HealthSyncMessage message)
|
||||
{
|
||||
using var writer = new NetworkWriter();
|
||||
writer.Write(message.NetworkId);
|
||||
writer.Write(message.NormalizedHealth);
|
||||
return writer.ToArray();
|
||||
}
|
||||
|
||||
public static HealthSyncMessage Deserialize(byte[] buffer)
|
||||
{
|
||||
using var reader = new NetworkReader(buffer);
|
||||
int id = reader.ReadInt();
|
||||
float normalized = reader.ReadFloat();
|
||||
return new HealthSyncMessage(id, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct ProjectileSpawnMessage
|
||||
{
|
||||
public readonly int NetworkId;
|
||||
public readonly int WeaponIndex;
|
||||
public readonly Vector3 Position;
|
||||
public readonly Vector3 Direction;
|
||||
public readonly float Speed;
|
||||
public readonly float Life;
|
||||
public readonly float Damage;
|
||||
|
||||
public ProjectileSpawnMessage(int networkId, int weaponIndex, Vector3 position, Vector3 direction, float speed, float life, float damage)
|
||||
{
|
||||
NetworkId = networkId;
|
||||
WeaponIndex = weaponIndex;
|
||||
Position = position;
|
||||
Direction = direction;
|
||||
Speed = speed;
|
||||
Life = life;
|
||||
Damage = damage;
|
||||
}
|
||||
|
||||
public static byte[] Serialize(ProjectileSpawnMessage message)
|
||||
{
|
||||
using var writer = new NetworkWriter();
|
||||
writer.Write(message.NetworkId);
|
||||
writer.Write(message.WeaponIndex);
|
||||
writer.Write(message.Position);
|
||||
writer.Write(message.Direction);
|
||||
writer.Write(message.Speed);
|
||||
writer.Write(message.Life);
|
||||
writer.Write(message.Damage);
|
||||
return writer.ToArray();
|
||||
}
|
||||
|
||||
public static ProjectileSpawnMessage Deserialize(byte[] buffer)
|
||||
{
|
||||
using var reader = new NetworkReader(buffer);
|
||||
int networkId = reader.ReadInt();
|
||||
int index = reader.ReadInt();
|
||||
Vector3 position = reader.ReadVector3();
|
||||
Vector3 direction = reader.ReadVector3();
|
||||
float speed = reader.ReadFloat();
|
||||
float life = reader.ReadFloat();
|
||||
float damage = reader.ReadFloat();
|
||||
return new ProjectileSpawnMessage(networkId, index, position, direction, speed, life, damage);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Networking/NetworkMessages.cs.meta
Normal file
2
Game/Scripts/Networking/NetworkMessages.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 28ade96a26da5d9f693df8a5a80b0970
|
||||
116
Game/Scripts/Networking/NetworkSerialization.cs
Normal file
116
Game/Scripts/Networking/NetworkSerialization.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
public sealed class NetworkWriter : IDisposable
|
||||
{
|
||||
private readonly MemoryStream stream;
|
||||
private readonly BinaryWriter writer;
|
||||
|
||||
public NetworkWriter(int capacity = 128)
|
||||
{
|
||||
stream = new MemoryStream(capacity);
|
||||
writer = new BinaryWriter(stream);
|
||||
}
|
||||
|
||||
public void Write(byte value) => writer.Write(value);
|
||||
public void Write(int value) => writer.Write(value);
|
||||
public void Write(uint value) => writer.Write(value);
|
||||
public void Write(short value) => writer.Write(value);
|
||||
public void Write(ushort value) => writer.Write(value);
|
||||
public void Write(long value) => writer.Write(value);
|
||||
public void Write(ulong value) => writer.Write(value);
|
||||
public void Write(float value) => writer.Write(value);
|
||||
public void Write(bool value) => writer.Write(value);
|
||||
|
||||
public void Write(Vector3 value)
|
||||
{
|
||||
writer.Write(value.x);
|
||||
writer.Write(value.y);
|
||||
writer.Write(value.z);
|
||||
}
|
||||
|
||||
public void Write(Quaternion value)
|
||||
{
|
||||
writer.Write(value.x);
|
||||
writer.Write(value.y);
|
||||
writer.Write(value.z);
|
||||
writer.Write(value.w);
|
||||
}
|
||||
|
||||
public void Write(string value) => writer.Write(value ?? string.Empty);
|
||||
|
||||
public byte[] ToArray()
|
||||
{
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
writer?.Dispose();
|
||||
stream?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NetworkReader : IDisposable
|
||||
{
|
||||
private readonly MemoryStream stream;
|
||||
private readonly BinaryReader reader;
|
||||
|
||||
public NetworkReader(byte[] buffer)
|
||||
{
|
||||
stream = new MemoryStream(buffer ?? Array.Empty<byte>());
|
||||
reader = new BinaryReader(stream);
|
||||
}
|
||||
|
||||
public byte ReadByte() => reader.ReadByte();
|
||||
public bool ReadBool() => reader.ReadBoolean();
|
||||
public int ReadInt() => reader.ReadInt32();
|
||||
public uint ReadUInt() => reader.ReadUInt32();
|
||||
public short ReadShort() => reader.ReadInt16();
|
||||
public ushort ReadUShort() => reader.ReadUInt16();
|
||||
public long ReadLong() => reader.ReadInt64();
|
||||
public ulong ReadULong() => reader.ReadUInt64();
|
||||
public float ReadFloat() => reader.ReadSingle();
|
||||
|
||||
public Vector3 ReadVector3()
|
||||
{
|
||||
float x = reader.ReadSingle();
|
||||
float y = reader.ReadSingle();
|
||||
float z = reader.ReadSingle();
|
||||
return new Vector3(x, y, z);
|
||||
}
|
||||
|
||||
public Quaternion ReadQuaternion()
|
||||
{
|
||||
float x = reader.ReadSingle();
|
||||
float y = reader.ReadSingle();
|
||||
float z = reader.ReadSingle();
|
||||
float w = reader.ReadSingle();
|
||||
return new Quaternion(x, y, z, w);
|
||||
}
|
||||
|
||||
public string ReadString() => reader.ReadString();
|
||||
|
||||
public bool TryReadBytes(int length, out byte[] bytes)
|
||||
{
|
||||
if (length <= 0 || reader.BaseStream.Position + length > reader.BaseStream.Length)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
bytes = reader.ReadBytes(length);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
reader?.Dispose();
|
||||
stream?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Networking/NetworkSerialization.cs.meta
Normal file
2
Game/Scripts/Networking/NetworkSerialization.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0303a875e193063078b4557274bb3c5a
|
||||
101
Game/Scripts/Networking/SteamBootstrap.cs
Normal file
101
Game/Scripts/Networking/SteamBootstrap.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using Steamworks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles SteamAPI initialization and callback pumping. Place once in the initial scene.
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-1000)]
|
||||
public class SteamBootstrap : MonoBehaviour
|
||||
{
|
||||
private static bool isInitialized;
|
||||
private static SteamBootstrap instance;
|
||||
|
||||
public static bool IsInitialized => isInitialized;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (instance != null)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
|
||||
TryInitializeSteam();
|
||||
}
|
||||
|
||||
private void TryInitializeSteam()
|
||||
{
|
||||
if (isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Packsize.Test())
|
||||
{
|
||||
Debug.LogError("[SteamBootstrap] Packsize Test returned false. Wrong Steamworks binaries for this platform?");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DllCheck.Test())
|
||||
{
|
||||
Debug.LogError("[SteamBootstrap] DllCheck Test returned false. Missing Steamworks dependencies.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
isInitialized = SteamAPI.Init();
|
||||
}
|
||||
catch (System.DllNotFoundException e)
|
||||
{
|
||||
Debug.LogError("[SteamBootstrap] Steamworks native binaries not found: " + e.Message);
|
||||
isInitialized = false;
|
||||
}
|
||||
|
||||
if (!isInitialized)
|
||||
{
|
||||
Debug.LogError("[SteamBootstrap] Failed to initialize Steam API.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log("[SteamBootstrap] Steam API initialized for user " + SteamFriends.GetPersonaName());
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SteamAPI.RunCallbacks();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (instance == this)
|
||||
{
|
||||
ShutdownSteam();
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShutdownSteam()
|
||||
{
|
||||
if (!isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SteamAPI.Shutdown();
|
||||
isInitialized = false;
|
||||
Debug.Log("[SteamBootstrap] Steam API shutdown.");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Networking/SteamBootstrap.cs.meta
Normal file
2
Game/Scripts/Networking/SteamBootstrap.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f741ec3a1d16461a98d02ec211293449
|
||||
261
Game/Scripts/Networking/SteamCharacterNetworkBridge.cs
Normal file
261
Game/Scripts/Networking/SteamCharacterNetworkBridge.cs
Normal file
@@ -0,0 +1,261 @@
|
||||
using Steamworks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public class SteamCharacterNetworkBridge : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private ThirdPersonCharacterController characterController;
|
||||
[SerializeField] private NetworkIdentity identity;
|
||||
[SerializeField] private Transform rootTransform;
|
||||
[SerializeField] private NetworkCharacterInputProxy networkInputProxy;
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float transformBroadcastInterval = 0.05f;
|
||||
[SerializeField] private float remoteLerpSpeed = 12f;
|
||||
[SerializeField] private ulong ownerSteamId;
|
||||
[SerializeField] private bool autoAssignOwnerToLocalPlayer = true;
|
||||
|
||||
private SteamCoopNetworkManager networkManager;
|
||||
private float broadcastTimer;
|
||||
private bool isRegistered;
|
||||
private bool isLocalPlayer;
|
||||
private bool isAuthority;
|
||||
|
||||
private Vector3 remoteTargetPosition;
|
||||
private Quaternion remoteTargetRotation;
|
||||
private Vector3 remoteTargetVelocity;
|
||||
private bool haveRemoteState;
|
||||
private bool localOverrideSet;
|
||||
|
||||
public void AssignOwner(ulong steamId, bool localPlayer)
|
||||
{
|
||||
ownerSteamId = steamId;
|
||||
isLocalPlayer = localPlayer;
|
||||
localOverrideSet = true;
|
||||
UpdateAuthority();
|
||||
ConfigureController();
|
||||
}
|
||||
|
||||
public bool IsLocalPlayer => isLocalPlayer;
|
||||
public bool IsAuthority => isAuthority;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (characterController == null)
|
||||
{
|
||||
characterController = GetComponent<ThirdPersonCharacterController>();
|
||||
}
|
||||
|
||||
if (identity == null)
|
||||
{
|
||||
identity = GetComponent<NetworkIdentity>();
|
||||
}
|
||||
|
||||
if (rootTransform == null)
|
||||
{
|
||||
rootTransform = transform;
|
||||
}
|
||||
|
||||
if (networkInputProxy == null)
|
||||
{
|
||||
networkInputProxy = GetComponent<NetworkCharacterInputProxy>();
|
||||
if (networkInputProxy == null)
|
||||
{
|
||||
networkInputProxy = gameObject.AddComponent<NetworkCharacterInputProxy>();
|
||||
networkInputProxy.hideFlags = HideFlags.HideInInspector;
|
||||
}
|
||||
}
|
||||
|
||||
remoteTargetPosition = rootTransform.position;
|
||||
remoteTargetRotation = rootTransform.rotation;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
networkManager = SteamCoopNetworkManager.Instance;
|
||||
UpdateAuthority();
|
||||
ConfigureController();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
networkManager = SteamCoopNetworkManager.Instance;
|
||||
RegisterHandlers();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
UnregisterHandlers();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (networkManager == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthority)
|
||||
{
|
||||
broadcastTimer -= Time.deltaTime;
|
||||
if (broadcastTimer <= 0f)
|
||||
{
|
||||
BroadcastTransform();
|
||||
broadcastTimer = transformBroadcastInterval;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (haveRemoteState)
|
||||
{
|
||||
rootTransform.position = Vector3.Lerp(rootTransform.position, remoteTargetPosition, remoteLerpSpeed * Time.deltaTime);
|
||||
rootTransform.rotation = Quaternion.Slerp(rootTransform.rotation, remoteTargetRotation, remoteLerpSpeed * Time.deltaTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterHandlers()
|
||||
{
|
||||
if (networkManager == null || isRegistered)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
networkManager.RegisterHandler(NetworkMessageType.PlayerInput, HandlePlayerInputMessage);
|
||||
networkManager.RegisterHandler(NetworkMessageType.CharacterTransform, HandleCharacterTransformMessage);
|
||||
isRegistered = true;
|
||||
}
|
||||
|
||||
private void UnregisterHandlers()
|
||||
{
|
||||
if (networkManager == null || !isRegistered)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
networkManager.UnregisterHandler(NetworkMessageType.PlayerInput, HandlePlayerInputMessage);
|
||||
networkManager.UnregisterHandler(NetworkMessageType.CharacterTransform, HandleCharacterTransformMessage);
|
||||
isRegistered = false;
|
||||
}
|
||||
|
||||
private void UpdateAuthority()
|
||||
{
|
||||
if (networkManager == null)
|
||||
{
|
||||
networkManager = SteamCoopNetworkManager.Instance;
|
||||
}
|
||||
|
||||
bool isHost = networkManager != null && networkManager.IsHost;
|
||||
ulong localSteamId = SteamBootstrap.IsInitialized ? SteamUser.GetSteamID().m_SteamID : 0UL;
|
||||
|
||||
if (!localOverrideSet && autoAssignOwnerToLocalPlayer && ownerSteamId == 0 && localSteamId != 0)
|
||||
{
|
||||
ownerSteamId = localSteamId;
|
||||
}
|
||||
|
||||
if (!localOverrideSet)
|
||||
{
|
||||
isLocalPlayer = ownerSteamId != 0 && ownerSteamId == localSteamId;
|
||||
}
|
||||
|
||||
isAuthority = isHost; // Host drives authoritative simulation.
|
||||
}
|
||||
|
||||
private void ConfigureController()
|
||||
{
|
||||
if (characterController == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthority)
|
||||
{
|
||||
characterController.enabled = true;
|
||||
characterController.SetInputSource(isLocalPlayer ? null : networkInputProxy);
|
||||
}
|
||||
else
|
||||
{
|
||||
characterController.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void BroadcastTransform()
|
||||
{
|
||||
if (identity == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var unityController = GetComponent<CharacterController>();
|
||||
Vector3 velocity = unityController != null ? unityController.velocity : Vector3.zero;
|
||||
var message = new CharacterTransformMessage(identity.NetworkId, rootTransform.position, rootTransform.rotation, velocity);
|
||||
byte[] payload = CharacterTransformMessage.Serialize(message);
|
||||
networkManager.SendToAll(NetworkMessageType.CharacterTransform, payload, EP2PSend.k_EP2PSendUnreliableNoDelay);
|
||||
}
|
||||
|
||||
private void HandlePlayerInputMessage(NetworkMessage message)
|
||||
{
|
||||
if (!isAuthority)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerInputMessage inputMessage = PlayerInputMessage.Deserialize(message.Payload);
|
||||
if (inputMessage.NetworkId != identity.NetworkId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
networkInputProxy.SetInput(inputMessage.MoveInput, inputMessage.JumpPressed);
|
||||
}
|
||||
|
||||
private void HandleCharacterTransformMessage(NetworkMessage message)
|
||||
{
|
||||
if (isAuthority)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CharacterTransformMessage transformMessage = CharacterTransformMessage.Deserialize(message.Payload);
|
||||
if (transformMessage.NetworkId != identity.NetworkId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
remoteTargetPosition = transformMessage.Position;
|
||||
remoteTargetRotation = transformMessage.Rotation;
|
||||
remoteTargetVelocity = transformMessage.Velocity;
|
||||
haveRemoteState = true;
|
||||
}
|
||||
|
||||
public void SendLocalInput(Vector2 moveInput, bool jump)
|
||||
{
|
||||
if (networkManager == null || identity == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = new PlayerInputMessage(identity.NetworkId, moveInput, jump, Vector2.zero);
|
||||
byte[] payload = PlayerInputMessage.Serialize(message);
|
||||
if (!networkManager.IsConnected || networkManager.IsHost)
|
||||
{
|
||||
// If we are host, feed directly.
|
||||
HandlePlayerInputMessage(new NetworkMessage(NetworkMessageType.PlayerInput, payload, SteamUser.GetSteamID().m_SteamID));
|
||||
}
|
||||
else
|
||||
{
|
||||
CSteamID lobby = networkManager.ActiveLobby;
|
||||
CSteamID lobbyOwner = lobby != CSteamID.Nil ? SteamMatchmaking.GetLobbyOwner(lobby) : CSteamID.Nil;
|
||||
if (lobbyOwner == CSteamID.Nil)
|
||||
{
|
||||
lobbyOwner = SteamUser.GetSteamID();
|
||||
}
|
||||
|
||||
networkManager.SendToPlayer(lobbyOwner, NetworkMessageType.PlayerInput, payload, EP2PSend.k_EP2PSendUnreliableNoDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a010df74dca8515c3af51a9fc598af17
|
||||
165
Game/Scripts/Networking/SteamCoopNetworkManager.cs
Normal file
165
Game/Scripts/Networking/SteamCoopNetworkManager.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Steamworks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
/// <summary>
|
||||
/// High level orchestrator for Steam lobby + P2P messaging. Keeps track of handlers per message type.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class SteamCoopNetworkManager : MonoBehaviour
|
||||
{
|
||||
public static SteamCoopNetworkManager Instance { get; private set; }
|
||||
|
||||
[SerializeField] private SteamLobbyManager lobbyManager;
|
||||
[SerializeField] private SteamP2PTransport p2pTransport;
|
||||
|
||||
private readonly Dictionary<NetworkMessageType, Action<NetworkMessage>> handlers = new();
|
||||
private bool isHost;
|
||||
private bool isConnected;
|
||||
|
||||
public bool IsHost => isHost;
|
||||
public bool IsConnected => isConnected;
|
||||
public CSteamID ActiveLobby => lobbyManager != null ? lobbyManager.GetActiveLobby() : CSteamID.Nil;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
|
||||
if (lobbyManager == null)
|
||||
{
|
||||
lobbyManager = GetComponentInChildren<SteamLobbyManager>();
|
||||
}
|
||||
|
||||
if (p2pTransport == null)
|
||||
{
|
||||
p2pTransport = GetComponentInChildren<SteamP2PTransport>();
|
||||
}
|
||||
|
||||
if (lobbyManager != null)
|
||||
{
|
||||
lobbyManager.LobbyCreated += HandleLobbyCreated;
|
||||
lobbyManager.LobbyJoined += HandleLobbyJoined;
|
||||
lobbyManager.LobbyMemberJoined += HandleLobbyMemberJoined;
|
||||
lobbyManager.LobbyMemberLeft += HandleLobbyMemberLeft;
|
||||
}
|
||||
|
||||
if (p2pTransport != null)
|
||||
{
|
||||
p2pTransport.MessageReceived += DispatchMessage;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (Instance == this)
|
||||
{
|
||||
Instance = null;
|
||||
}
|
||||
|
||||
if (lobbyManager != null)
|
||||
{
|
||||
lobbyManager.LobbyCreated -= HandleLobbyCreated;
|
||||
lobbyManager.LobbyJoined -= HandleLobbyJoined;
|
||||
lobbyManager.LobbyMemberJoined -= HandleLobbyMemberJoined;
|
||||
lobbyManager.LobbyMemberLeft -= HandleLobbyMemberLeft;
|
||||
}
|
||||
|
||||
if (p2pTransport != null)
|
||||
{
|
||||
p2pTransport.MessageReceived -= DispatchMessage;
|
||||
}
|
||||
}
|
||||
|
||||
public void RegisterHandler(NetworkMessageType type, Action<NetworkMessage> handler)
|
||||
{
|
||||
if (handlers.TryGetValue(type, out Action<NetworkMessage> existing))
|
||||
{
|
||||
existing += handler;
|
||||
handlers[type] = existing;
|
||||
}
|
||||
else
|
||||
{
|
||||
handlers[type] = handler;
|
||||
}
|
||||
}
|
||||
|
||||
public void UnregisterHandler(NetworkMessageType type, Action<NetworkMessage> handler)
|
||||
{
|
||||
if (!handlers.TryGetValue(type, out Action<NetworkMessage> existing))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
existing -= handler;
|
||||
if (existing == null)
|
||||
{
|
||||
handlers.Remove(type);
|
||||
}
|
||||
else
|
||||
{
|
||||
handlers[type] = existing;
|
||||
}
|
||||
}
|
||||
|
||||
public void SendToAll(NetworkMessageType type, byte[] payload, EP2PSend sendType = EP2PSend.k_EP2PSendReliable)
|
||||
{
|
||||
p2pTransport?.Broadcast(type, payload, sendType);
|
||||
}
|
||||
|
||||
public void SendToPlayer(CSteamID target, NetworkMessageType type, byte[] payload, EP2PSend sendType = EP2PSend.k_EP2PSendReliable)
|
||||
{
|
||||
p2pTransport?.Send(target, type, payload, sendType);
|
||||
}
|
||||
|
||||
private void DispatchMessage(NetworkMessage message)
|
||||
{
|
||||
if (handlers.TryGetValue(message.Type, out Action<NetworkMessage> handler))
|
||||
{
|
||||
handler?.Invoke(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleLobbyCreated(CSteamID lobbyId)
|
||||
{
|
||||
isHost = true;
|
||||
isConnected = true;
|
||||
p2pTransport?.SetActiveLobby(lobbyId);
|
||||
}
|
||||
|
||||
private void HandleLobbyJoined(CSteamID lobbyId)
|
||||
{
|
||||
isConnected = true;
|
||||
p2pTransport?.SetActiveLobby(lobbyId);
|
||||
|
||||
string ownerId = SteamMatchmaking.GetLobbyData(lobbyId, "owner");
|
||||
if (!string.IsNullOrEmpty(ownerId) && ulong.TryParse(ownerId, out ulong ownerSteamId))
|
||||
{
|
||||
isHost = ownerSteamId == SteamUser.GetSteamID().m_SteamID;
|
||||
}
|
||||
else
|
||||
{
|
||||
isHost = SteamMatchmaking.GetLobbyOwner(lobbyId) == SteamUser.GetSteamID();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleLobbyMemberJoined(CSteamID member)
|
||||
{
|
||||
Debug.Log("[SteamCoopNetworkManager] Member joined: " + member);
|
||||
}
|
||||
|
||||
private void HandleLobbyMemberLeft(CSteamID member)
|
||||
{
|
||||
Debug.Log("[SteamCoopNetworkManager] Member left: " + member);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Networking/SteamCoopNetworkManager.cs.meta
Normal file
2
Game/Scripts/Networking/SteamCoopNetworkManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 83320aed3c99a87b692932447a34631e
|
||||
89
Game/Scripts/Networking/SteamHealthNetworkBridge.cs
Normal file
89
Game/Scripts/Networking/SteamHealthNetworkBridge.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public class SteamHealthNetworkBridge : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Combat.Health health;
|
||||
[SerializeField] private NetworkIdentity identity;
|
||||
|
||||
private SteamCoopNetworkManager networkManager;
|
||||
private bool isRegistered;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (health == null)
|
||||
{
|
||||
health = GetComponent<Combat.Health>();
|
||||
}
|
||||
|
||||
if (identity == null)
|
||||
{
|
||||
identity = GetComponent<NetworkIdentity>();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
networkManager = SteamCoopNetworkManager.Instance;
|
||||
if (networkManager != null)
|
||||
{
|
||||
networkManager.RegisterHandler(NetworkMessageType.HealthSync, HandleHealthSync);
|
||||
isRegistered = true;
|
||||
}
|
||||
|
||||
if (health != null)
|
||||
{
|
||||
health.NormalizedHealthChanged += OnHealthChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (isRegistered && networkManager != null)
|
||||
{
|
||||
networkManager.UnregisterHandler(NetworkMessageType.HealthSync, HandleHealthSync);
|
||||
isRegistered = false;
|
||||
}
|
||||
|
||||
if (health != null)
|
||||
{
|
||||
health.NormalizedHealthChanged -= OnHealthChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsAuthority()
|
||||
{
|
||||
return networkManager == null || networkManager.IsHost;
|
||||
}
|
||||
|
||||
private void OnHealthChanged(float normalized)
|
||||
{
|
||||
if (!IsAuthority() || identity == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = new HealthSyncMessage(identity.NetworkId, normalized);
|
||||
byte[] payload = HealthSyncMessage.Serialize(message);
|
||||
networkManager.SendToAll(NetworkMessageType.HealthSync, payload, Steamworks.EP2PSend.k_EP2PSendReliable);
|
||||
}
|
||||
|
||||
private void HandleHealthSync(NetworkMessage message)
|
||||
{
|
||||
if (IsAuthority() || identity == null || health == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HealthSyncMessage syncMessage = HealthSyncMessage.Deserialize(message.Payload);
|
||||
if (syncMessage.NetworkId != identity.NetworkId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
health.ForceSetNormalizedHealth(syncMessage.NormalizedHealth);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Networking/SteamHealthNetworkBridge.cs.meta
Normal file
2
Game/Scripts/Networking/SteamHealthNetworkBridge.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a55005e32d7c7fdd89d4c15872d0466d
|
||||
116
Game/Scripts/Networking/SteamLobbyManager.cs
Normal file
116
Game/Scripts/Networking/SteamLobbyManager.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using Steamworks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
/// <summary>
|
||||
/// Wraps Steam lobby creation and join logic for cooperative sessions.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class SteamLobbyManager : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private int maxPlayers = 4;
|
||||
|
||||
public event Action<LobbyDataUpdate_t> LobbyDataUpdated;
|
||||
public event Action<CSteamID> LobbyCreated;
|
||||
public event Action<CSteamID> LobbyJoined;
|
||||
public event Action<CSteamID> LobbyMemberJoined;
|
||||
public event Action<CSteamID> LobbyMemberLeft;
|
||||
|
||||
private Callback<LobbyCreated_t> lobbyCreatedCallback;
|
||||
private Callback<LobbyEnter_t> lobbyEnterCallback;
|
||||
private Callback<LobbyDataUpdate_t> lobbyDataUpdateCallback;
|
||||
private Callback<LobbyChatUpdate_t> lobbyChatUpdateCallback;
|
||||
|
||||
private CSteamID activeLobbyId;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
lobbyCreatedCallback = Callback<LobbyCreated_t>.Create(OnLobbyCreated);
|
||||
lobbyEnterCallback = Callback<LobbyEnter_t>.Create(OnLobbyEntered);
|
||||
lobbyDataUpdateCallback = Callback<LobbyDataUpdate_t>.Create(OnLobbyDataUpdated);
|
||||
lobbyChatUpdateCallback = Callback<LobbyChatUpdate_t>.Create(OnLobbyChatUpdate);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
lobbyCreatedCallback?.Dispose();
|
||||
lobbyEnterCallback?.Dispose();
|
||||
lobbyDataUpdateCallback?.Dispose();
|
||||
lobbyChatUpdateCallback?.Dispose();
|
||||
}
|
||||
|
||||
public void HostLobby(string lobbyName)
|
||||
{
|
||||
if (!SteamBootstrap.IsInitialized || !SteamAPI.IsSteamRunning())
|
||||
{
|
||||
Debug.LogWarning("[SteamLobbyManager] Steam is not initialized; cannot create lobby.");
|
||||
return;
|
||||
}
|
||||
|
||||
SteamMatchmaking.CreateLobby(ELobbyType.k_ELobbyTypePublic, Mathf.Max(2, maxPlayers));
|
||||
pendingLobbyName = lobbyName;
|
||||
}
|
||||
|
||||
public void JoinLobby(CSteamID lobbyId)
|
||||
{
|
||||
if (!SteamBootstrap.IsInitialized || !SteamAPI.IsSteamRunning())
|
||||
{
|
||||
Debug.LogWarning("[SteamLobbyManager] Steam not running; cannot join lobby.");
|
||||
return;
|
||||
}
|
||||
|
||||
SteamMatchmaking.JoinLobby(lobbyId);
|
||||
}
|
||||
|
||||
public CSteamID GetActiveLobby() => activeLobbyId;
|
||||
|
||||
private string pendingLobbyName;
|
||||
|
||||
private void OnLobbyCreated(LobbyCreated_t callback)
|
||||
{
|
||||
if (callback.m_eResult != EResult.k_EResultOK)
|
||||
{
|
||||
Debug.LogError("[SteamLobbyManager] Lobby creation failed: " + callback.m_eResult);
|
||||
return;
|
||||
}
|
||||
|
||||
activeLobbyId = new CSteamID(callback.m_ulSteamIDLobby);
|
||||
SteamMatchmaking.SetLobbyData(activeLobbyId, "name", string.IsNullOrEmpty(pendingLobbyName) ? "MegaKoop Lobby" : pendingLobbyName);
|
||||
SteamMatchmaking.SetLobbyData(activeLobbyId, "owner", SteamUser.GetSteamID().ToString());
|
||||
LobbyCreated?.Invoke(activeLobbyId);
|
||||
Debug.Log("[SteamLobbyManager] Lobby created " + activeLobbyId);
|
||||
}
|
||||
|
||||
private void OnLobbyEntered(LobbyEnter_t callback)
|
||||
{
|
||||
activeLobbyId = new CSteamID(callback.m_ulSteamIDLobby);
|
||||
LobbyJoined?.Invoke(activeLobbyId);
|
||||
Debug.Log("[SteamLobbyManager] Entered lobby " + activeLobbyId);
|
||||
}
|
||||
|
||||
private void OnLobbyDataUpdated(LobbyDataUpdate_t callback)
|
||||
{
|
||||
LobbyDataUpdated?.Invoke(callback);
|
||||
}
|
||||
|
||||
private void OnLobbyChatUpdate(LobbyChatUpdate_t callback)
|
||||
{
|
||||
CSteamID lobby = new CSteamID(callback.m_ulSteamIDLobby);
|
||||
CSteamID changedUser = new CSteamID(callback.m_ulSteamIDUserChanged);
|
||||
|
||||
EChatMemberStateChange stateChange = (EChatMemberStateChange)callback.m_rgfChatMemberStateChange;
|
||||
|
||||
if ((stateChange & EChatMemberStateChange.k_EChatMemberStateChangeEntered) != 0)
|
||||
{
|
||||
LobbyMemberJoined?.Invoke(changedUser);
|
||||
}
|
||||
|
||||
if ((stateChange & (EChatMemberStateChange.k_EChatMemberStateChangeLeft | EChatMemberStateChange.k_EChatMemberStateChangeDisconnected | EChatMemberStateChange.k_EChatMemberStateChangeKicked | EChatMemberStateChange.k_EChatMemberStateChangeBanned)) != 0)
|
||||
{
|
||||
LobbyMemberLeft?.Invoke(changedUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Networking/SteamLobbyManager.cs.meta
Normal file
2
Game/Scripts/Networking/SteamLobbyManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 468b0a865fb2a0b0181db1c60d4e0ea9
|
||||
44
Game/Scripts/Networking/SteamLocalInputSender.cs
Normal file
44
Game/Scripts/Networking/SteamLocalInputSender.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public class SteamLocalInputSender : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private SteamCharacterNetworkBridge characterNetwork;
|
||||
[SerializeField] private float sendInterval = 0.05f;
|
||||
|
||||
private float sendTimer;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (characterNetwork == null)
|
||||
{
|
||||
characterNetwork = GetComponent<SteamCharacterNetworkBridge>();
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (characterNetwork == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!characterNetwork.IsLocalPlayer || characterNetwork.IsAuthority)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sendTimer -= Time.deltaTime;
|
||||
Vector2 moveInput = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
|
||||
bool jumpPressed = Input.GetButtonDown("Jump");
|
||||
|
||||
if (sendTimer <= 0f || jumpPressed)
|
||||
{
|
||||
characterNetwork.SendLocalInput(moveInput, jumpPressed);
|
||||
sendTimer = sendInterval;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Networking/SteamLocalInputSender.cs.meta
Normal file
2
Game/Scripts/Networking/SteamLocalInputSender.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 07c0ee7fbf4c1edaf9992cf139839928
|
||||
114
Game/Scripts/Networking/SteamP2PTransport.cs
Normal file
114
Game/Scripts/Networking/SteamP2PTransport.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using Steamworks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
/// <summary>
|
||||
/// Low level Steam P2P messaging helper. Prepends a byte message type header for easy routing.
|
||||
/// </summary>
|
||||
public class SteamP2PTransport : MonoBehaviour
|
||||
{
|
||||
public event Action<NetworkMessage> MessageReceived;
|
||||
|
||||
[SerializeField] private EP2PSend defaultSendType = EP2PSend.k_EP2PSendReliable;
|
||||
[SerializeField] private int listenChannel = 0;
|
||||
|
||||
private byte[] receiveBuffer = new byte[8192];
|
||||
private CSteamID activeLobby = CSteamID.Nil;
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!SteamBootstrap.IsInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PumpIncomingPackets();
|
||||
}
|
||||
|
||||
public void SetActiveLobby(CSteamID lobbyId)
|
||||
{
|
||||
activeLobby = lobbyId;
|
||||
}
|
||||
|
||||
public void Send(CSteamID recipient, NetworkMessageType type, byte[] payload, EP2PSend sendType = EP2PSend.k_EP2PSendReliable)
|
||||
{
|
||||
if (!SteamBootstrap.IsInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int payloadLength = payload != null ? payload.Length : 0;
|
||||
byte[] packet = new byte[payloadLength + 1];
|
||||
packet[0] = (byte)type;
|
||||
if (payloadLength > 0)
|
||||
{
|
||||
Buffer.BlockCopy(payload, 0, packet, 1, payloadLength);
|
||||
}
|
||||
|
||||
bool sent = SteamNetworking.SendP2PPacket(recipient, packet, (uint)packet.Length, sendType, listenChannel);
|
||||
if (!sent)
|
||||
{
|
||||
Debug.LogWarning("[SteamP2PTransport] Failed to send packet to " + recipient);
|
||||
}
|
||||
}
|
||||
|
||||
public void Broadcast(NetworkMessageType type, byte[] payload, EP2PSend sendType = EP2PSend.k_EP2PSendReliable)
|
||||
{
|
||||
if (activeLobby == CSteamID.Nil)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int members = SteamMatchmaking.GetNumLobbyMembers(activeLobby);
|
||||
CSteamID self = SteamUser.GetSteamID();
|
||||
|
||||
for (int i = 0; i < members; i++)
|
||||
{
|
||||
CSteamID member = SteamMatchmaking.GetLobbyMemberByIndex(activeLobby, i);
|
||||
if (member == self)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Send(member, type, payload, sendType);
|
||||
}
|
||||
}
|
||||
|
||||
private void PumpIncomingPackets()
|
||||
{
|
||||
while (SteamNetworking.IsP2PPacketAvailable(out uint packetSize, listenChannel))
|
||||
{
|
||||
if (packetSize == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (packetSize > receiveBuffer.Length)
|
||||
{
|
||||
receiveBuffer = new byte[(int)packetSize];
|
||||
}
|
||||
|
||||
if (SteamNetworking.ReadP2PPacket(receiveBuffer, (uint)receiveBuffer.Length, out uint bytesRead, out CSteamID remote, listenChannel))
|
||||
{
|
||||
SteamNetworking.AcceptP2PSessionWithUser(remote);
|
||||
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
NetworkMessageType type = (NetworkMessageType)receiveBuffer[0];
|
||||
byte[] payload = new byte[Mathf.Max(0, (int)bytesRead - 1)];
|
||||
if (bytesRead > 1)
|
||||
{
|
||||
Buffer.BlockCopy(receiveBuffer, 1, payload, 0, (int)bytesRead - 1);
|
||||
}
|
||||
|
||||
MessageReceived?.Invoke(new NetworkMessage(type, payload, remote.m_SteamID));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Networking/SteamP2PTransport.cs.meta
Normal file
2
Game/Scripts/Networking/SteamP2PTransport.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c3923950963f6f6a98a81fda48267e6e
|
||||
128
Game/Scripts/Networking/SteamWeaponNetworkBridge.cs
Normal file
128
Game/Scripts/Networking/SteamWeaponNetworkBridge.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using MegaKoop.Game.WeaponSystem;
|
||||
using Steamworks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Networking
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public class SteamWeaponNetworkBridge : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private WeaponController weaponController;
|
||||
[SerializeField] private NetworkIdentity identity;
|
||||
[SerializeField] private bool disableLocalFiringWhenClient = true;
|
||||
|
||||
private SteamCoopNetworkManager networkManager;
|
||||
private bool isRegistered;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
networkManager = SteamCoopNetworkManager.Instance;
|
||||
|
||||
if (weaponController == null)
|
||||
{
|
||||
weaponController = GetComponent<WeaponController>();
|
||||
}
|
||||
|
||||
if (identity == null)
|
||||
{
|
||||
identity = GetComponent<NetworkIdentity>();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (weaponController == null || identity == null)
|
||||
{
|
||||
Debug.LogWarning("[SteamWeaponNetworkBridge] Missing references.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (networkManager == null)
|
||||
{
|
||||
networkManager = SteamCoopNetworkManager.Instance;
|
||||
}
|
||||
|
||||
if (networkManager != null)
|
||||
{
|
||||
networkManager.RegisterHandler(NetworkMessageType.ProjectileSpawn, HandleProjectileSpawnMessage);
|
||||
isRegistered = true;
|
||||
}
|
||||
|
||||
weaponController.ProjectileSpawned += OnProjectileSpawned;
|
||||
|
||||
if (!IsAuthoritative() && disableLocalFiringWhenClient)
|
||||
{
|
||||
weaponController.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (weaponController != null)
|
||||
{
|
||||
weaponController.ProjectileSpawned -= OnProjectileSpawned;
|
||||
|
||||
if (!IsAuthoritative() && disableLocalFiringWhenClient)
|
||||
{
|
||||
weaponController.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRegistered && networkManager != null)
|
||||
{
|
||||
networkManager.UnregisterHandler(NetworkMessageType.ProjectileSpawn, HandleProjectileSpawnMessage);
|
||||
isRegistered = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsAuthoritative()
|
||||
{
|
||||
if (networkManager == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return networkManager.IsHost;
|
||||
}
|
||||
|
||||
private void OnProjectileSpawned(WeaponController.ProjectileSpawnEvent spawnEvent)
|
||||
{
|
||||
if (!IsAuthoritative())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (networkManager == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = new ProjectileSpawnMessage(identity.NetworkId, spawnEvent.WeaponIndex, spawnEvent.Position, spawnEvent.Direction, spawnEvent.Speed, spawnEvent.Lifetime, spawnEvent.Damage);
|
||||
byte[] payload = ProjectileSpawnMessage.Serialize(message);
|
||||
networkManager.SendToAll(NetworkMessageType.ProjectileSpawn, payload, EP2PSend.k_EP2PSendUnreliableNoDelay);
|
||||
}
|
||||
|
||||
private void HandleProjectileSpawnMessage(NetworkMessage message)
|
||||
{
|
||||
if (message.Sender == SteamUser.GetSteamID().m_SteamID)
|
||||
{
|
||||
// Ignore our own echo.
|
||||
return;
|
||||
}
|
||||
|
||||
ProjectileSpawnMessage spawnMessage = ProjectileSpawnMessage.Deserialize(message.Payload);
|
||||
if (spawnMessage.NetworkId != identity.NetworkId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsAuthoritative())
|
||||
{
|
||||
// Host already spawned real projectile, don't duplicate.
|
||||
return;
|
||||
}
|
||||
|
||||
weaponController.SpawnNetworkProjectile(spawnMessage.WeaponIndex, spawnMessage.Position, spawnMessage.Direction, spawnMessage.Speed, spawnMessage.Life, spawnMessage.Damage);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Networking/SteamWeaponNetworkBridge.cs.meta
Normal file
2
Game/Scripts/Networking/SteamWeaponNetworkBridge.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dad61aa7b24bddb6b9add5a461263779
|
||||
204
Game/Scripts/ThirdPersonCamera.cs
Normal file
204
Game/Scripts/ThirdPersonCamera.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game
|
||||
{
|
||||
public class ThirdPersonCamera : MonoBehaviour
|
||||
{
|
||||
[Header("Target")]
|
||||
[SerializeField] private Transform target;
|
||||
[SerializeField] private Vector3 focusOffset = new Vector3(0f, 1.6f, 0f);
|
||||
|
||||
[Header("Orbit")]
|
||||
[SerializeField] private float mouseSensitivity = 180f;
|
||||
[SerializeField] private float minPitch = -35f;
|
||||
[SerializeField] private float maxPitch = 75f;
|
||||
[SerializeField] private float rotationSmoothTime = 0.1f;
|
||||
|
||||
[Header("Distance")]
|
||||
[SerializeField] private float distance = 5f;
|
||||
[SerializeField] private float minDistance = 2f;
|
||||
[SerializeField] private float maxDistance = 8f;
|
||||
[SerializeField] private float zoomSpeed = 4f;
|
||||
[SerializeField] private float distanceSmoothTime = 0.1f;
|
||||
|
||||
[Header("Collision")]
|
||||
[SerializeField] private float obstructionRadius = 0.25f;
|
||||
[SerializeField] private LayerMask obstructionMask = ~0;
|
||||
[SerializeField] private float obstructionBuffer = 0.1f;
|
||||
|
||||
[Header("Cursor")]
|
||||
[SerializeField] private bool lockCursor = true;
|
||||
|
||||
private Vector2 orbitAngles = new Vector2(20f, 0f);
|
||||
private Vector2 currentOrbitAngles;
|
||||
private float pitchVelocity;
|
||||
private float yawVelocity;
|
||||
private float desiredDistance;
|
||||
private float distanceVelocity;
|
||||
private static readonly RaycastHit[] ObstructionHits = new RaycastHit[8];
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
desiredDistance = Mathf.Clamp(distance, minDistance, maxDistance);
|
||||
distance = desiredDistance;
|
||||
currentOrbitAngles = orbitAngles;
|
||||
pitchVelocity = yawVelocity = 0f;
|
||||
|
||||
if (lockCursor)
|
||||
{
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (lockCursor)
|
||||
{
|
||||
Cursor.lockState = CursorLockMode.None;
|
||||
Cursor.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (target == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float deltaTime = Time.deltaTime;
|
||||
ReadOrbitInput(deltaTime);
|
||||
ReadZoomInput();
|
||||
|
||||
if (rotationSmoothTime <= 0f)
|
||||
{
|
||||
currentOrbitAngles = orbitAngles;
|
||||
pitchVelocity = yawVelocity = 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentOrbitAngles.x = Mathf.SmoothDamp(currentOrbitAngles.x, orbitAngles.x, ref pitchVelocity, rotationSmoothTime, Mathf.Infinity, deltaTime);
|
||||
currentOrbitAngles.y = Mathf.SmoothDamp(currentOrbitAngles.y, orbitAngles.y, ref yawVelocity, rotationSmoothTime, Mathf.Infinity, deltaTime);
|
||||
}
|
||||
Quaternion lookRotation = Quaternion.Euler(currentOrbitAngles.x, currentOrbitAngles.y, 0f);
|
||||
|
||||
Vector3 focusPoint = target.TransformPoint(focusOffset);
|
||||
float smoothedDistance;
|
||||
if (distanceSmoothTime <= 0f)
|
||||
{
|
||||
distanceVelocity = 0f;
|
||||
smoothedDistance = desiredDistance;
|
||||
}
|
||||
else
|
||||
{
|
||||
smoothedDistance = Mathf.SmoothDamp(distance, desiredDistance, ref distanceVelocity, distanceSmoothTime, Mathf.Infinity, deltaTime);
|
||||
}
|
||||
float unobstructedDistance = smoothedDistance;
|
||||
float adjustedDistance = ResolveObstructions(focusPoint, lookRotation, unobstructedDistance);
|
||||
float finalDistance = Mathf.Min(unobstructedDistance, adjustedDistance);
|
||||
Vector3 finalPosition = focusPoint - lookRotation * Vector3.forward * finalDistance;
|
||||
|
||||
transform.SetPositionAndRotation(finalPosition, lookRotation);
|
||||
distance = finalDistance;
|
||||
}
|
||||
|
||||
private void ReadOrbitInput(float deltaTime)
|
||||
{
|
||||
float lookX = Input.GetAxis("Mouse X");
|
||||
float lookY = Input.GetAxis("Mouse Y");
|
||||
|
||||
if (Mathf.Abs(lookX) > 0.0001f || Mathf.Abs(lookY) > 0.0001f)
|
||||
{
|
||||
orbitAngles.y += lookX * mouseSensitivity * deltaTime;
|
||||
orbitAngles.x -= lookY * mouseSensitivity * deltaTime;
|
||||
orbitAngles.x = Mathf.Clamp(orbitAngles.x, minPitch, maxPitch);
|
||||
}
|
||||
}
|
||||
|
||||
private void ReadZoomInput()
|
||||
{
|
||||
float scroll = Input.GetAxis("Mouse ScrollWheel");
|
||||
if (Mathf.Abs(scroll) > 0.0001f)
|
||||
{
|
||||
desiredDistance = Mathf.Clamp(desiredDistance - scroll * zoomSpeed, minDistance, maxDistance);
|
||||
}
|
||||
else
|
||||
{
|
||||
desiredDistance = Mathf.Clamp(desiredDistance, minDistance, maxDistance);
|
||||
}
|
||||
}
|
||||
|
||||
private float ResolveObstructions(Vector3 focusPoint, Quaternion lookRotation, float targetDistance)
|
||||
{
|
||||
if (targetDistance <= 0.001f)
|
||||
{
|
||||
return targetDistance;
|
||||
}
|
||||
|
||||
Vector3 direction = lookRotation * Vector3.back;
|
||||
int hitCount = Physics.SphereCastNonAlloc(focusPoint, obstructionRadius, direction, ObstructionHits, targetDistance, obstructionMask, QueryTriggerInteraction.Ignore);
|
||||
|
||||
if (hitCount == 0)
|
||||
{
|
||||
return targetDistance;
|
||||
}
|
||||
|
||||
float closestDistance = targetDistance;
|
||||
|
||||
for (int i = 0; i < hitCount; i++)
|
||||
{
|
||||
RaycastHit hit = ObstructionHits[i];
|
||||
if (hit.collider == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Transform hitTransform = hit.collider.transform;
|
||||
if (target != null && (hitTransform == target || hitTransform.IsChildOf(target)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hit.distance < closestDistance)
|
||||
{
|
||||
closestDistance = Mathf.Max(0f, hit.distance - obstructionBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
return closestDistance;
|
||||
}
|
||||
|
||||
public void SetTarget(Transform newTarget)
|
||||
{
|
||||
target = newTarget;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
minPitch = Mathf.Clamp(minPitch, -89f, 89f);
|
||||
maxPitch = Mathf.Clamp(maxPitch, -89f, 89f);
|
||||
if (maxPitch < minPitch)
|
||||
{
|
||||
float temp = maxPitch;
|
||||
maxPitch = minPitch;
|
||||
minPitch = temp;
|
||||
}
|
||||
|
||||
mouseSensitivity = Mathf.Max(0f, mouseSensitivity);
|
||||
zoomSpeed = Mathf.Max(0f, zoomSpeed);
|
||||
obstructionRadius = Mathf.Max(0f, obstructionRadius);
|
||||
obstructionBuffer = Mathf.Clamp(obstructionBuffer, 0f, 1f);
|
||||
rotationSmoothTime = Mathf.Max(0f, rotationSmoothTime);
|
||||
distanceSmoothTime = Mathf.Max(0f, distanceSmoothTime);
|
||||
|
||||
minDistance = Mathf.Max(0.1f, minDistance);
|
||||
maxDistance = Mathf.Max(minDistance, maxDistance);
|
||||
distance = Mathf.Clamp(distance, minDistance, maxDistance);
|
||||
desiredDistance = Mathf.Clamp(distance, minDistance, maxDistance);
|
||||
|
||||
currentOrbitAngles = orbitAngles;
|
||||
pitchVelocity = yawVelocity = 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/ThirdPersonCamera.cs.meta
Normal file
2
Game/Scripts/ThirdPersonCamera.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3ecae0254451c2888cfe94f7a8e825d
|
||||
196
Game/Scripts/ThirdPersonCharacterController.cs
Normal file
196
Game/Scripts/ThirdPersonCharacterController.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game
|
||||
{
|
||||
[RequireComponent(typeof(UnityEngine.CharacterController))]
|
||||
public class ThirdPersonCharacterController : MonoBehaviour
|
||||
{
|
||||
[Header("Movement")]
|
||||
[SerializeField] private float moveSpeed = 5f;
|
||||
[SerializeField] private float rotationSharpness = 12f;
|
||||
|
||||
[Header("Air Control")]
|
||||
[SerializeField] private float airControlResponsiveness = 50f;
|
||||
|
||||
[Header("Jump")]
|
||||
[SerializeField] private float jumpHeight = 1.6f;
|
||||
[SerializeField] private float gravity = -20f;
|
||||
[SerializeField] private float groundedGravity = -5f;
|
||||
|
||||
[Header("Camera Reference")]
|
||||
[SerializeField] private Transform cameraTransform;
|
||||
|
||||
private UnityEngine.CharacterController characterController;
|
||||
private Vector3 planarVelocity;
|
||||
private float verticalVelocity;
|
||||
private bool isGrounded;
|
||||
private MegaKoop.Game.Networking.ICharacterInputSource inputSource;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
characterController = GetComponent<UnityEngine.CharacterController>();
|
||||
|
||||
if (cameraTransform == null)
|
||||
{
|
||||
Camera mainCamera = Camera.main;
|
||||
if (mainCamera != null)
|
||||
{
|
||||
cameraTransform = mainCamera.transform;
|
||||
}
|
||||
}
|
||||
|
||||
isGrounded = characterController.isGrounded;
|
||||
if (isGrounded)
|
||||
{
|
||||
verticalVelocity = groundedGravity;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
Vector2 moveInput = ReadMovementInput();
|
||||
Vector3 desiredMove = CalculateDesiredMove(moveInput);
|
||||
bool hasMoveInput = desiredMove.sqrMagnitude > 0f;
|
||||
|
||||
UpdatePlanarVelocity(desiredMove, hasMoveInput);
|
||||
TryRotateTowardsMovement(desiredMove, hasMoveInput);
|
||||
|
||||
UpdateGroundedStateBeforeGravity();
|
||||
HandleJumpInput();
|
||||
ApplyGravity();
|
||||
|
||||
Vector3 velocity = planarVelocity;
|
||||
velocity.y = verticalVelocity;
|
||||
|
||||
CollisionFlags collisionFlags = characterController.Move(velocity * Time.deltaTime);
|
||||
isGrounded = (collisionFlags & CollisionFlags.Below) != 0;
|
||||
|
||||
if (isGrounded && verticalVelocity < 0f)
|
||||
{
|
||||
verticalVelocity = groundedGravity;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetInputSource(MegaKoop.Game.Networking.ICharacterInputSource source)
|
||||
{
|
||||
inputSource = source;
|
||||
}
|
||||
|
||||
private Vector2 ReadMovementInput()
|
||||
{
|
||||
if (inputSource != null)
|
||||
{
|
||||
Vector2 sourceInput = inputSource.MoveInput;
|
||||
return Vector2.ClampMagnitude(sourceInput, 1f);
|
||||
}
|
||||
|
||||
float horizontal = Input.GetAxisRaw("Horizontal");
|
||||
float vertical = Input.GetAxisRaw("Vertical");
|
||||
Vector2 input = new Vector2(horizontal, vertical);
|
||||
input = Vector2.ClampMagnitude(input, 1f);
|
||||
return input;
|
||||
}
|
||||
|
||||
private Vector3 CalculateDesiredMove(Vector2 input)
|
||||
{
|
||||
Vector3 forward = Vector3.forward;
|
||||
Vector3 right = Vector3.right;
|
||||
|
||||
if (cameraTransform != null)
|
||||
{
|
||||
forward = cameraTransform.forward;
|
||||
right = cameraTransform.right;
|
||||
}
|
||||
|
||||
forward.y = 0f;
|
||||
right.y = 0f;
|
||||
|
||||
forward.Normalize();
|
||||
right.Normalize();
|
||||
|
||||
Vector3 desiredMove = forward * input.y + right * input.x;
|
||||
if (desiredMove.sqrMagnitude > 1f)
|
||||
{
|
||||
desiredMove.Normalize();
|
||||
}
|
||||
|
||||
return desiredMove;
|
||||
}
|
||||
|
||||
private void UpdatePlanarVelocity(Vector3 desiredMove, bool hasMoveInput)
|
||||
{
|
||||
if (isGrounded)
|
||||
{
|
||||
planarVelocity = hasMoveInput ? desiredMove * moveSpeed : Vector3.zero;
|
||||
return;
|
||||
}
|
||||
|
||||
if (airControlResponsiveness <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 targetVelocity = hasMoveInput ? desiredMove * moveSpeed : Vector3.zero;
|
||||
float maxDelta = airControlResponsiveness * Time.deltaTime;
|
||||
planarVelocity = Vector3.MoveTowards(planarVelocity, targetVelocity, maxDelta);
|
||||
}
|
||||
|
||||
private void TryRotateTowardsMovement(Vector3 desiredMove, bool hasMoveInput)
|
||||
{
|
||||
if (!hasMoveInput)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Quaternion targetRotation = Quaternion.LookRotation(desiredMove, Vector3.up);
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSharpness * Time.deltaTime);
|
||||
}
|
||||
|
||||
private void UpdateGroundedStateBeforeGravity()
|
||||
{
|
||||
if (isGrounded && verticalVelocity < 0f)
|
||||
{
|
||||
verticalVelocity = groundedGravity;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleJumpInput()
|
||||
{
|
||||
if (!isGrounded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ShouldJumpThisFrame())
|
||||
{
|
||||
verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
|
||||
isGrounded = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldJumpThisFrame()
|
||||
{
|
||||
if (inputSource != null)
|
||||
{
|
||||
return inputSource.JumpPressed;
|
||||
}
|
||||
|
||||
return Input.GetButtonDown("Jump");
|
||||
}
|
||||
|
||||
private void ApplyGravity()
|
||||
{
|
||||
verticalVelocity += gravity * Time.deltaTime;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
moveSpeed = Mathf.Max(0f, moveSpeed);
|
||||
rotationSharpness = Mathf.Max(0f, rotationSharpness);
|
||||
jumpHeight = Mathf.Max(0f, jumpHeight);
|
||||
airControlResponsiveness = Mathf.Max(0f, airControlResponsiveness);
|
||||
gravity = Mathf.Min(-0.01f, gravity);
|
||||
groundedGravity = Mathf.Clamp(groundedGravity, gravity, 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Game/Scripts/ThirdPersonCharacterController.cs.meta
Normal file
11
Game/Scripts/ThirdPersonCharacterController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9309e1c5110afc714b0b9b9d10323469
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Game/Scripts/UI.meta
Normal file
8
Game/Scripts/UI.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee0194cba591c20dfa044c4c9c8e595f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
41
Game/Scripts/UI/FontAssetPreloader.cs
Normal file
41
Game/Scripts/UI/FontAssetPreloader.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.TextCore.Text;
|
||||
|
||||
namespace MegaKoop.Game.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Preloads required characters into dynamic font atlases on the main thread to avoid runtime race conditions.
|
||||
/// Attach once to a bootstrap GameObject and assign all UI font assets used by UITK.
|
||||
/// </summary>
|
||||
public sealed class FontAssetPreloader : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private FontAsset[] fontAssets;
|
||||
[SerializeField, TextArea(1, 4)] private string charactersToPreload = "…•-";
|
||||
|
||||
private System.Collections.IEnumerator Start()
|
||||
{
|
||||
if (fontAssets == null || fontAssets.Length == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Wait one frame to ensure the scene finished loading on the main thread.
|
||||
yield return null;
|
||||
|
||||
foreach (FontAsset asset in fontAssets)
|
||||
{
|
||||
if (asset == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to add characters that are commonly generated at runtime (ellipsis, bullets, dashes, etc.).
|
||||
bool allAdded = asset.TryAddCharacters(charactersToPreload, out string missingCharacters);
|
||||
if (!allAdded && !string.IsNullOrEmpty(missingCharacters))
|
||||
{
|
||||
Debug.LogWarning($"[FontAssetPreloader] Missing glyphs '{missingCharacters}' in font '{asset.name}'. Consider adding a fallback font.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/UI/FontAssetPreloader.cs.meta
Normal file
2
Game/Scripts/UI/FontAssetPreloader.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5f85fa1035779f1bbba9cd75923e55d0
|
||||
534
Game/Scripts/WeaponController.cs
Normal file
534
Game/Scripts/WeaponController.cs
Normal file
@@ -0,0 +1,534 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MegaKoop.Game.Combat;
|
||||
using MegaKoop.Game.WeaponSystem;
|
||||
using UnityEngine;
|
||||
using Random = UnityEngine.Random;
|
||||
|
||||
namespace MegaKoop.Game.WeaponSystem
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public class WeaponController : MonoBehaviour
|
||||
{
|
||||
[Header("Ownership")]
|
||||
[SerializeField] private Team ownerTeam = Team.Heroes;
|
||||
[SerializeField] private Transform weaponSocket;
|
||||
|
||||
[Header("Targeting")]
|
||||
[SerializeField] private float acquisitionRadius = 30f;
|
||||
[SerializeField] private float retargetInterval = 0.25f;
|
||||
[SerializeField] private LayerMask targetMask = Physics.DefaultRaycastLayers;
|
||||
[SerializeField] private LayerMask lineOfSightMask = Physics.DefaultRaycastLayers;
|
||||
[SerializeField] private bool requireLineOfSight = true;
|
||||
|
||||
[Header("Loadout")]
|
||||
[SerializeField] private WeaponDefinition[] startingWeapons;
|
||||
[SerializeField] private bool autoFire = true;
|
||||
|
||||
private static readonly RaycastHit[] LineOfSightHitsBuffer = new RaycastHit[8];
|
||||
|
||||
private readonly List<WeaponRuntime> equippedWeapons = new();
|
||||
private readonly HashSet<Collider> ignoredOwnerColliders = new();
|
||||
|
||||
public event Action<ProjectileSpawnEvent> ProjectileSpawned;
|
||||
|
||||
private float maxWeaponRange;
|
||||
private float retargetTimer;
|
||||
private TargetInfo currentTarget;
|
||||
private bool hasTarget;
|
||||
private Collider[] ownerColliders;
|
||||
private Health ownerHealth;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
ownerHealth = GetComponent<Health>();
|
||||
if (ownerHealth != null)
|
||||
{
|
||||
ownerTeam = ownerHealth.Team;
|
||||
}
|
||||
|
||||
ownerColliders = GetComponentsInChildren<Collider>();
|
||||
if (ownerColliders != null)
|
||||
{
|
||||
for (int i = 0; i < ownerColliders.Length; i++)
|
||||
{
|
||||
Collider ownerCollider = ownerColliders[i];
|
||||
if (ownerCollider == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ignoredOwnerColliders.Add(ownerCollider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (weaponSocket == null)
|
||||
{
|
||||
weaponSocket = transform;
|
||||
}
|
||||
|
||||
if (startingWeapons != null)
|
||||
{
|
||||
foreach (WeaponDefinition definition in startingWeapons)
|
||||
{
|
||||
Equip(definition, weaponSocket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (equippedWeapons.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoFire)
|
||||
{
|
||||
UpdateTargetSelection(Time.deltaTime);
|
||||
}
|
||||
|
||||
TargetInfo target = hasTarget ? currentTarget : default;
|
||||
|
||||
foreach (WeaponRuntime weapon in equippedWeapons)
|
||||
{
|
||||
weapon.Tick(Time.deltaTime, target, this);
|
||||
}
|
||||
}
|
||||
|
||||
public void Equip(WeaponDefinition definition, Transform mount)
|
||||
{
|
||||
if (definition == null || mount == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int weaponIndex = equippedWeapons.Count;
|
||||
WeaponRuntime runtime = new WeaponRuntime(definition, mount, weaponIndex);
|
||||
equippedWeapons.Add(runtime);
|
||||
maxWeaponRange = Mathf.Max(maxWeaponRange, definition.Range);
|
||||
}
|
||||
|
||||
public void Unequip(WeaponDefinition definition)
|
||||
{
|
||||
if (definition == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = equippedWeapons.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (equippedWeapons[i].Definition == definition)
|
||||
{
|
||||
equippedWeapons[i].Dispose();
|
||||
equippedWeapons.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
RecalculateMaxRange();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
foreach (WeaponRuntime weapon in equippedWeapons)
|
||||
{
|
||||
weapon.Dispose();
|
||||
}
|
||||
|
||||
equippedWeapons.Clear();
|
||||
hasTarget = false;
|
||||
}
|
||||
|
||||
private void UpdateTargetSelection(float deltaTime)
|
||||
{
|
||||
retargetTimer -= deltaTime;
|
||||
|
||||
if (hasTarget)
|
||||
{
|
||||
if (!currentTarget.IsValid)
|
||||
{
|
||||
hasTarget = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
float distance = Vector3.Distance(transform.position, currentTarget.Transform.position);
|
||||
float allowedRange = Mathf.Max(acquisitionRadius, maxWeaponRange);
|
||||
if (distance > allowedRange * 1.1f)
|
||||
{
|
||||
hasTarget = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (retargetTimer > 0f && hasTarget)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
retargetTimer = retargetInterval;
|
||||
AcquireTarget();
|
||||
}
|
||||
|
||||
private void AcquireTarget()
|
||||
{
|
||||
float searchRadius = Mathf.Max(acquisitionRadius, maxWeaponRange);
|
||||
if (searchRadius <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Collider[] hits = Physics.OverlapSphere(transform.position, searchRadius, targetMask, QueryTriggerInteraction.Collide);
|
||||
TargetInfo bestTarget = default;
|
||||
float bestScore = float.MaxValue;
|
||||
|
||||
foreach (Collider hit in hits)
|
||||
{
|
||||
if (hit == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredOwnerColliders.Contains(hit))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IDamageable damageable = hit.GetComponentInParent<IDamageable>();
|
||||
if (damageable == null || !damageable.IsAlive)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (damageable.Team == ownerTeam && ownerTeam != Team.Neutral)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (damageable is not Component component)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Transform targetTransform = component.transform;
|
||||
float sqrDistance = (targetTransform.position - transform.position).sqrMagnitude;
|
||||
if (sqrDistance > maxWeaponRange * maxWeaponRange)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float score = sqrDistance;
|
||||
if (score < bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestTarget = new TargetInfo(damageable, hit, targetTransform);
|
||||
}
|
||||
}
|
||||
|
||||
hasTarget = bestTarget.IsValid;
|
||||
currentTarget = bestTarget;
|
||||
}
|
||||
|
||||
private void RecalculateMaxRange()
|
||||
{
|
||||
maxWeaponRange = 0f;
|
||||
for (int i = 0; i < equippedWeapons.Count; i++)
|
||||
{
|
||||
WeaponRuntime weapon = equippedWeapons[i];
|
||||
weapon.SetIndex(i);
|
||||
maxWeaponRange = Mathf.Max(maxWeaponRange, weapon.Definition.Range);
|
||||
}
|
||||
}
|
||||
|
||||
private Vector3 GetAimPoint(TargetInfo target)
|
||||
{
|
||||
if (!target.IsValid)
|
||||
{
|
||||
return Vector3.zero;
|
||||
}
|
||||
|
||||
if (target.Collider != null)
|
||||
{
|
||||
return target.Collider.bounds.center;
|
||||
}
|
||||
|
||||
return target.Transform.position;
|
||||
}
|
||||
|
||||
private bool HasLineOfSight(Transform muzzle, Vector3 aimPoint, TargetInfo target)
|
||||
{
|
||||
if (!requireLineOfSight)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Vector3 origin = muzzle.position;
|
||||
Vector3 toTarget = aimPoint - origin;
|
||||
float distance = toTarget.magnitude;
|
||||
if (distance <= 0.01f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Vector3 direction = toTarget.normalized;
|
||||
int hitCount = Physics.RaycastNonAlloc(origin, direction, LineOfSightHitsBuffer, distance, lineOfSightMask, QueryTriggerInteraction.Ignore);
|
||||
|
||||
if (hitCount == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float targetDistance = float.MaxValue;
|
||||
bool hasTargetHit = false;
|
||||
|
||||
for (int i = 0; i < hitCount; i++)
|
||||
{
|
||||
RaycastHit hit = LineOfSightHitsBuffer[i];
|
||||
Collider hitCollider = hit.collider;
|
||||
if (hitCollider == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredOwnerColliders.Contains(hitCollider))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target.Collider != null && hitCollider == target.Collider)
|
||||
{
|
||||
targetDistance = hit.distance;
|
||||
hasTargetHit = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hit.distance < targetDistance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return hasTargetHit;
|
||||
}
|
||||
|
||||
private void SpawnProjectile(WeaponRuntime weapon, Transform muzzle, Vector3 direction)
|
||||
{
|
||||
Projectile projectilePrefab = weapon.Definition.ProjectilePrefab;
|
||||
if (projectilePrefab == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Quaternion rotation = direction.sqrMagnitude > 0f ? Quaternion.LookRotation(direction) : muzzle.rotation;
|
||||
Projectile projectile = Instantiate(projectilePrefab, muzzle.position, rotation);
|
||||
projectile.Initialize(direction, weapon.Definition.ProjectileSpeed, weapon.Definition.Damage, weapon.Definition.ProjectileLifetime, gameObject, ownerTeam, ownerColliders, weapon.Definition.HitMask);
|
||||
|
||||
ProjectileSpawned?.Invoke(new ProjectileSpawnEvent(weapon.Index, muzzle.position, direction, weapon.Definition.ProjectileSpeed, weapon.Definition.ProjectileLifetime, weapon.Definition.Damage));
|
||||
}
|
||||
|
||||
public void SpawnNetworkProjectile(int weaponIndex, Vector3 position, Vector3 direction, float speed, float lifetime, float damage)
|
||||
{
|
||||
if (weaponIndex < 0 || weaponIndex >= equippedWeapons.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WeaponRuntime weapon = equippedWeapons[weaponIndex];
|
||||
weapon.SpawnFromNetwork(position, direction, speed, lifetime, damage, ownerTeam, ownerColliders, gameObject);
|
||||
}
|
||||
|
||||
private void FireWeapon(WeaponRuntime weapon, TargetInfo target, Transform muzzle)
|
||||
{
|
||||
Vector3 aimPoint = GetAimPoint(target);
|
||||
Vector3 toTarget = aimPoint - muzzle.position;
|
||||
if (toTarget.sqrMagnitude <= 0.0001f)
|
||||
{
|
||||
toTarget = muzzle.forward;
|
||||
}
|
||||
|
||||
if (!HasLineOfSight(muzzle, aimPoint, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 baseDirection = toTarget.normalized;
|
||||
int projectiles = weapon.Definition.ProjectilesPerShot;
|
||||
float spread = weapon.Definition.SpreadAngle;
|
||||
|
||||
for (int i = 0; i < projectiles; i++)
|
||||
{
|
||||
Vector3 direction = baseDirection;
|
||||
if (spread > 0f)
|
||||
{
|
||||
Vector2 offset = Random.insideUnitCircle * spread;
|
||||
Quaternion spreadRotation = Quaternion.AngleAxis(offset.x, muzzle.up) * Quaternion.AngleAxis(offset.y, muzzle.right);
|
||||
direction = spreadRotation * baseDirection;
|
||||
}
|
||||
|
||||
SpawnProjectile(weapon, muzzle, direction);
|
||||
}
|
||||
|
||||
weapon.ResetCooldown();
|
||||
}
|
||||
|
||||
private readonly struct TargetInfo
|
||||
{
|
||||
public readonly IDamageable Damageable;
|
||||
public readonly Collider Collider;
|
||||
public readonly Transform Transform;
|
||||
|
||||
public TargetInfo(IDamageable damageable, Collider collider, Transform transform)
|
||||
{
|
||||
Damageable = damageable;
|
||||
Collider = collider;
|
||||
Transform = transform;
|
||||
}
|
||||
|
||||
public bool IsValid => Damageable != null && Damageable.IsAlive && Transform != null;
|
||||
}
|
||||
|
||||
private sealed class WeaponRuntime
|
||||
{
|
||||
private readonly Transform mountPoint;
|
||||
private readonly Transform fallbackMuzzle;
|
||||
private readonly Transform[] muzzles;
|
||||
private int muzzleIndex;
|
||||
private float cooldown;
|
||||
|
||||
public WeaponDefinition Definition { get; }
|
||||
public WeaponView ViewInstance { get; }
|
||||
public int Index { get; private set; }
|
||||
|
||||
public WeaponRuntime(WeaponDefinition definition, Transform mount, int index)
|
||||
{
|
||||
Definition = definition ?? throw new ArgumentNullException(nameof(definition));
|
||||
mountPoint = mount;
|
||||
Index = index;
|
||||
|
||||
if (definition.ViewPrefab != null)
|
||||
{
|
||||
ViewInstance = UnityEngine.Object.Instantiate(definition.ViewPrefab, mountPoint);
|
||||
ViewInstance.transform.localPosition = Vector3.zero;
|
||||
ViewInstance.transform.localRotation = Quaternion.identity;
|
||||
ViewInstance.transform.localScale = Vector3.one;
|
||||
muzzles = ViewInstance.Muzzles;
|
||||
}
|
||||
else
|
||||
{
|
||||
ViewInstance = null;
|
||||
muzzles = Array.Empty<Transform>();
|
||||
}
|
||||
|
||||
fallbackMuzzle = mountPoint;
|
||||
muzzleIndex = 0;
|
||||
cooldown = 0f;
|
||||
}
|
||||
|
||||
public void Tick(float deltaTime, TargetInfo target, WeaponController controller)
|
||||
{
|
||||
cooldown = Mathf.Max(0f, cooldown - deltaTime);
|
||||
if (cooldown > 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.IsValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Transform muzzle = GetMuzzle();
|
||||
if (muzzle == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 aimPoint = controller.GetAimPoint(target);
|
||||
Vector3 toTarget = aimPoint - muzzle.position;
|
||||
float sqrDistance = toTarget.sqrMagnitude;
|
||||
float maxRange = Definition.Range;
|
||||
if (sqrDistance > maxRange * maxRange)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
controller.FireWeapon(this, target, muzzle);
|
||||
}
|
||||
|
||||
public Transform GetMuzzle()
|
||||
{
|
||||
if (muzzles != null && muzzles.Length > 0)
|
||||
{
|
||||
for (int i = 0; i < muzzles.Length; i++)
|
||||
{
|
||||
int current = (muzzleIndex + i) % muzzles.Length;
|
||||
Transform muzzle = muzzles[current];
|
||||
if (muzzle != null)
|
||||
{
|
||||
muzzleIndex = (current + 1) % muzzles.Length;
|
||||
return muzzle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackMuzzle;
|
||||
}
|
||||
|
||||
public void ResetCooldown()
|
||||
{
|
||||
cooldown = Definition.FireInterval;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (ViewInstance != null)
|
||||
{
|
||||
UnityEngine.Object.Destroy(ViewInstance.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetIndex(int newIndex)
|
||||
{
|
||||
Index = newIndex;
|
||||
}
|
||||
|
||||
public void SpawnFromNetwork(Vector3 position, Vector3 direction, float speed, float lifetime, float damage, Team ownerTeam, Collider[] ownerColliders, GameObject owner)
|
||||
{
|
||||
Projectile projectilePrefab = Definition.ProjectilePrefab;
|
||||
if (projectilePrefab == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Quaternion rotation = direction.sqrMagnitude > 0f ? Quaternion.LookRotation(direction) : fallbackMuzzle.rotation;
|
||||
Projectile projectile = UnityEngine.Object.Instantiate(projectilePrefab, position, rotation);
|
||||
float resolvedSpeed = speed > 0f ? speed : Definition.ProjectileSpeed;
|
||||
float resolvedLifetime = lifetime > 0f ? lifetime : Definition.ProjectileLifetime;
|
||||
float resolvedDamage = damage > 0f ? damage : Definition.Damage;
|
||||
projectile.Initialize(direction, resolvedSpeed, resolvedDamage, resolvedLifetime, owner, ownerTeam, ownerColliders, Definition.HitMask);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct ProjectileSpawnEvent
|
||||
{
|
||||
public readonly int WeaponIndex;
|
||||
public readonly Vector3 Position;
|
||||
public readonly Vector3 Direction;
|
||||
public readonly float Speed;
|
||||
public readonly float Lifetime;
|
||||
public readonly float Damage;
|
||||
|
||||
public ProjectileSpawnEvent(int weaponIndex, Vector3 position, Vector3 direction, float speed, float lifetime, float damage)
|
||||
{
|
||||
WeaponIndex = weaponIndex;
|
||||
Position = position;
|
||||
Direction = direction;
|
||||
Speed = speed;
|
||||
Lifetime = lifetime;
|
||||
Damage = damage;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/WeaponController.cs.meta
Normal file
2
Game/Scripts/WeaponController.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e6e77e83dcc282364afb3428637c723c
|
||||
8
Game/Scripts/WeaponSystem.meta
Normal file
8
Game/Scripts/WeaponSystem.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 44d32c675960e4dceb05fc13f06e43bd
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
123
Game/Scripts/WeaponSystem/Projectile.cs
Normal file
123
Game/Scripts/WeaponSystem/Projectile.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using MegaKoop.Game.Combat;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.WeaponSystem
|
||||
{
|
||||
public class Projectile : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private float hitRadius = 0.05f;
|
||||
[SerializeField] private LayerMask hitMask = Physics.DefaultRaycastLayers;
|
||||
[SerializeField] private bool alignToVelocity = true;
|
||||
|
||||
private Vector3 direction;
|
||||
private float speed;
|
||||
private float damage;
|
||||
private float lifetime;
|
||||
private float timeAlive;
|
||||
private Team sourceTeam;
|
||||
private GameObject owner;
|
||||
private Collider[] projectileColliders;
|
||||
|
||||
public void Initialize(Vector3 shotDirection, float projectileSpeed, float damageAmount, float projectileLifetime, GameObject projectileOwner, Team ownerTeam, Collider[] ownerColliders, LayerMask mask)
|
||||
{
|
||||
direction = shotDirection.sqrMagnitude > 0f ? shotDirection.normalized : transform.forward;
|
||||
speed = Mathf.Max(0f, projectileSpeed);
|
||||
damage = Mathf.Max(0f, damageAmount);
|
||||
lifetime = Mathf.Max(0.01f, projectileLifetime);
|
||||
owner = projectileOwner;
|
||||
sourceTeam = ownerTeam;
|
||||
hitMask = mask;
|
||||
timeAlive = 0f;
|
||||
|
||||
projectileColliders ??= GetComponentsInChildren<Collider>();
|
||||
if (ownerColliders != null && projectileColliders != null)
|
||||
{
|
||||
foreach (Collider ownerCollider in ownerColliders)
|
||||
{
|
||||
if (ownerCollider == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (Collider projectileCollider in projectileColliders)
|
||||
{
|
||||
if (projectileCollider == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Physics.IgnoreCollision(projectileCollider, ownerCollider, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
float deltaTime = Time.deltaTime;
|
||||
Vector3 displacement = direction * speed * deltaTime;
|
||||
float distance = displacement.magnitude;
|
||||
|
||||
if (distance > 0f)
|
||||
{
|
||||
if (Physics.SphereCast(transform.position, hitRadius, direction, out RaycastHit hitInfo, distance, hitMask, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
TryHandleHit(hitInfo.collider, hitInfo.point, hitInfo.normal);
|
||||
return;
|
||||
}
|
||||
|
||||
transform.position += displacement;
|
||||
}
|
||||
|
||||
if (alignToVelocity && distance > 0f)
|
||||
{
|
||||
transform.forward = direction;
|
||||
}
|
||||
|
||||
timeAlive += deltaTime;
|
||||
if (timeAlive >= lifetime)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
TryHandleHit(other, transform.position, -direction);
|
||||
}
|
||||
|
||||
private void TryHandleHit(Collider other, Vector3 hitPoint, Vector3 hitNormal)
|
||||
{
|
||||
if (other == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (owner != null)
|
||||
{
|
||||
if (other.transform.IsChildOf(owner.transform))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
IDamageable damageable = other.GetComponentInParent<IDamageable>();
|
||||
if (damageable != null && damageable.IsAlive)
|
||||
{
|
||||
bool isFriendly = damageable.Team == sourceTeam && damageable.Team != Team.Neutral;
|
||||
if (isFriendly)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (damage > 0f)
|
||||
{
|
||||
var payload = new DamagePayload(damage, hitPoint, hitNormal, owner, sourceTeam, other.gameObject);
|
||||
damageable.ApplyDamage(payload);
|
||||
}
|
||||
}
|
||||
|
||||
Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/WeaponSystem/Projectile.cs.meta
Normal file
2
Game/Scripts/WeaponSystem/Projectile.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 038cb7cb48b0cb10da7ebba4ba473c63
|
||||
48
Game/Scripts/WeaponSystem/WeaponDefinition.cs
Normal file
48
Game/Scripts/WeaponSystem/WeaponDefinition.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.WeaponSystem
|
||||
{
|
||||
[CreateAssetMenu(fileName = "WeaponDefinition", menuName = "MegaKoop/Weapons/Weapon Definition", order = 0)]
|
||||
public class WeaponDefinition : ScriptableObject
|
||||
{
|
||||
[Header("Presentation")]
|
||||
[SerializeField] private string displayName = "Weapon";
|
||||
[SerializeField] private WeaponView viewPrefab;
|
||||
|
||||
[Header("Projectile")]
|
||||
[SerializeField] private Projectile projectilePrefab;
|
||||
[SerializeField] private float projectileSpeed = 25f;
|
||||
[SerializeField] private float projectileLifetime = 3f;
|
||||
|
||||
[Header("Firing")]
|
||||
[SerializeField] private float shotsPerSecond = 2f;
|
||||
[SerializeField] private float baseDamage = 10f;
|
||||
[SerializeField] private float range = 25f;
|
||||
[SerializeField] private int projectilesPerShot = 1;
|
||||
[SerializeField, Range(0f, 45f)] private float spreadAngle = 0f;
|
||||
[SerializeField] private LayerMask hitMask = Physics.DefaultRaycastLayers;
|
||||
|
||||
public string DisplayName => displayName;
|
||||
public WeaponView ViewPrefab => viewPrefab;
|
||||
public Projectile ProjectilePrefab => projectilePrefab;
|
||||
public float ProjectileSpeed => Mathf.Max(0f, projectileSpeed);
|
||||
public float ProjectileLifetime => Mathf.Max(0.05f, projectileLifetime);
|
||||
public float FireInterval => shotsPerSecond <= 0f ? float.MaxValue : 1f / shotsPerSecond;
|
||||
public float Damage => Mathf.Max(0f, baseDamage);
|
||||
public float Range => Mathf.Max(0f, range);
|
||||
public int ProjectilesPerShot => Mathf.Max(1, projectilesPerShot);
|
||||
public float SpreadAngle => Mathf.Abs(spreadAngle);
|
||||
public LayerMask HitMask => hitMask;
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
projectileSpeed = Mathf.Max(0f, projectileSpeed);
|
||||
projectileLifetime = Mathf.Max(0.05f, projectileLifetime);
|
||||
shotsPerSecond = Mathf.Max(0.01f, shotsPerSecond);
|
||||
baseDamage = Mathf.Max(0f, baseDamage);
|
||||
range = Mathf.Max(0f, range);
|
||||
projectilesPerShot = Mathf.Max(1, projectilesPerShot);
|
||||
spreadAngle = Mathf.Clamp(spreadAngle, 0f, 90f);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/WeaponSystem/WeaponDefinition.cs.meta
Normal file
2
Game/Scripts/WeaponSystem/WeaponDefinition.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2fd5ff95d6e2be5d09b6c054570cef0a
|
||||
43
Game/Scripts/WeaponSystem/WeaponView.cs
Normal file
43
Game/Scripts/WeaponSystem/WeaponView.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.WeaponSystem
|
||||
{
|
||||
public class WeaponView : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Transform[] muzzles;
|
||||
|
||||
public Transform[] Muzzles => muzzles;
|
||||
|
||||
public Transform GetMuzzle(int index = 0)
|
||||
{
|
||||
if (muzzles == null || muzzles.Length == 0)
|
||||
{
|
||||
return transform;
|
||||
}
|
||||
|
||||
index = Mathf.Clamp(index, 0, muzzles.Length - 1);
|
||||
return muzzles[index] == null ? transform : muzzles[index];
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (muzzles == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Gizmos.color = Color.cyan;
|
||||
foreach (Transform muzzle in muzzles)
|
||||
{
|
||||
if (muzzle == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Gizmos.DrawLine(muzzle.position, muzzle.position + muzzle.forward * 0.5f);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/WeaponSystem/WeaponView.cs.meta
Normal file
2
Game/Scripts/WeaponSystem/WeaponView.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5590d430717d95f878b7929af7bf28ff
|
||||
Reference in New Issue
Block a user